Compare commits

...

6 Commits

Author SHA1 Message Date
Sol Fisher Romanoff 22fdb50d88
feat: account switching 2023-02-07 18:36:41 +02:00
yanchan09 9e04e4fd80 Improve emoji picker performance (#275)
A simple virtual scroller is now used for the emoji grid. This avoids loading all emoji images at once, saving network bandwidth and reducing load on the server, while also putting less work on the browser's DOM and layout engine.

Co-authored-by: yan <yan@omg.lol>
Reviewed-on: AkkomaGang/akkoma-fe#275
Co-authored-by: yanchan09 <yan@omg.lol>
Co-committed-by: yanchan09 <yan@omg.lol>
2023-02-04 21:10:06 +00:00
floatingghost 88d5149db5 paginate-follow-requests (#277)
Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: AkkomaGang/akkoma-fe#277
2023-02-04 21:09:09 +00:00
floatingghost b4b13d777f Merge pull request 'Add indicator to user card if user blocks you' (#274) from eris/pleroma-fe:block-indicator into develop
Reviewed-on: AkkomaGang/akkoma-fe#274
2023-01-27 10:08:17 +00:00
eris 7f4dd9ff03 Disable follow button if blocked by user 2023-01-27 00:30:47 +00:00
eris a9a95e9120 Add indicator if user blocks you 2023-01-27 00:30:30 +00:00
31 changed files with 557 additions and 290 deletions

View File

@ -349,7 +349,7 @@ const checkOAuthToken = async ({ store }) => {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
if (store.getters.getUserToken()) { if (store.getters.getUserToken()) {
try { try {
await store.dispatch('loginUser', store.getters.getUserToken()) await store.dispatch('loginUser', store.getters.getUserToken()[store.state.users.lastLoginName])
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }

View File

@ -0,0 +1,52 @@
import Popover from '../popover/popover.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import RichContent from '../rich_content/rich_content.jsx'
import { map } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faRightToBracket,
faUserPen
} from '@fortawesome/free-solid-svg-icons'
library.add(
faRightToBracket,
faUserPen
)
const AccountSwitcher = {
components: {
Popover,
UserAvatar,
RichContent
},
computed: {
currentUser () {
return this.$store.state.users.currentUser
},
accounts () {
return map(Object.keys(this.$store.state.oauth.userToken), username => (
this.$store.getters.findUser(username)
))
},
registrationOpen () {
return this.$store.state.instance.registrationOpen
}
},
methods: {
login () {
this.$store.commit('beginAccountSwitch')
this.$router.push({ name: 'login' })
},
switchAccount (username, token) {
// don't switch to same user
if (username !== this.currentUser.screen_name) {
this.$store.commit('beginAccountSwitch')
this.$store.dispatch('loginUser', this.$store.state.oauth.userToken[username]).then(() => {
this.$router.push({ name: 'friends' })
})
}
}
}
}
export default AccountSwitcher

View File

@ -0,0 +1,90 @@
<template>
<Popover
trigger="click"
class="SwitchAccounts"
:bound-to="{ x: 'container' }"
:offset="{ x: -16 }"
>
<template #trigger>
<button
class="button-unstyled switch-account-button"
>
<FAIcon
fixed-width
class="icon"
icon="user-plus"
:title="$t('user_card.switch_accounts')"
/>
</button>
</template>
<template #content>
<div class="dropdown-menu">
<button
v-for="account in accounts"
class="button-default dropdown-item account-button"
:class="account.id === currentUser.id ? 'selected' : ''"
:key="account.screen_name"
@click="switchAccount(account.screen_name)"
>
<UserAvatar
:compact="true"
:user="account"
/>
<div class="right-side">
<RichContent
class="username"
:title="'@'+account.screen_name"
:html="account.name_html"
:emoji="account.emoji"
/>
<a>@{{ account.screen_name }}</a>
</div>
</button>
<div
role="separator"
class="dropdown-divider"
/>
<button
class="button-default dropdown-item dropdown-item-icon"
@click="login"
>
<FAIcon icon="right-to-bracket" />{{ $t('login.login') }}
</button>
<button
v-if="registrationOpen"
class="button-default dropdown-item dropdown-item-icon"
@click="register"
>
<FAIcon icon="user-pen" />{{ $t('login.register') }}
</button>
</div>
</template>
</Popover>
</template>
<script src="./account_switcher.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.button-default.dropdown-item.account-button {
display: flex;
&.selected {
background-color: var(--selectedPost, $fallback--lightBg);
}
.Avatar {
margin-right: 0.75em;
}
.right-side {
margin: auto;
}
.username {
font-weight: bolder;
margin-right: 0.4em;
}
}
</style>

View File

@ -0,0 +1,133 @@
const EMOJI_SIZE = 32 + 8
const GROUP_TITLE_HEIGHT = 24
const BUFFER_SIZE = 3 * EMOJI_SIZE
const EmojiGrid = {
props: {
groups: {
required: true,
type: Array
}
},
data () {
return {
containerWidth: 0,
containerHeight: 0,
scrollPos: 0,
resizeObserver: null
}
},
mounted () {
const rect = this.$refs.container.getBoundingClientRect()
this.containerWidth = rect.width
this.containerHeight = rect.height
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.containerWidth = entry.contentRect.width
this.containerHeight = entry.contentRect.height
}
})
this.resizeObserver.observe(this.$refs.container)
},
beforeUnmount () {
this.resizeObserver.disconnect()
this.resizeObserver = null
},
watch: {
groups () {
// Scroll to top when grid content changes
if (this.$refs.container) {
this.$refs.container.scrollTo(0, 0)
}
},
activeGroup (group) {
this.$emit('activeGroup', group)
}
},
methods: {
onScroll () {
this.scrollPos = this.$refs.container.scrollTop
},
onEmoji (emoji) {
this.$emit('emoji', emoji)
},
scrollToItem (itemId) {
const container = this.$refs.container
if (!container) return
for (const item of this.itemList) {
if (item.id === itemId) {
container.scrollTo(0, item.position.y)
return
}
}
}
},
computed: {
// Total height of scroller content
gridHeight () {
if (this.itemList.length === 0) return 0
const lastItem = this.itemList[this.itemList.length - 1]
return (
lastItem.position.y +
('title' in lastItem ? GROUP_TITLE_HEIGHT : EMOJI_SIZE)
)
},
activeGroup () {
const items = this.itemList
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]
if ('title' in item && item.position.y <= this.scrollPos) {
return item.id
}
}
return null
},
itemList () {
const items = []
let x = 0
let y = 0
for (const group of this.groups) {
items.push({ position: { x, y }, id: group.id, title: group.text })
if (group.text.length) {
y += GROUP_TITLE_HEIGHT
}
for (const emoji of group.emojis) {
items.push({
position: { x, y },
id: `${group.id}-${emoji.displayText}`,
emoji
})
x += EMOJI_SIZE
if (x + EMOJI_SIZE > this.containerWidth) {
y += EMOJI_SIZE
x = 0
}
}
if (x > 0) {
y += EMOJI_SIZE
x = 0
}
}
return items
},
visibleItems () {
const startPos = this.scrollPos - BUFFER_SIZE
const endPos = this.scrollPos + this.containerHeight + BUFFER_SIZE
return this.itemList.filter((i) => {
return i.position.y >= startPos && i.position.y < endPos
})
},
scrolledClass () {
if (this.scrollPos <= 5) {
return 'scrolled-top'
} else if (this.scrollPos >= this.gridHeight - this.containerHeight - 5) {
return 'scrolled-bottom'
} else {
return 'scrolled-middle'
}
}
}
}
export default EmojiGrid

View File

@ -0,0 +1,60 @@
.emoji {
&-grid {
flex: 1 1 1px;
position: relative;
overflow: auto;
user-select: none;
mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
linear-gradient(to top, white, white);
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
-webkit-mask-composite: xor;
mask-composite: exclude;
&.scrolled {
&-top {
mask-size: 100% 20px, 100% 0, auto;
}
&-bottom {
mask-size: 100% 0, 100% 20px, auto;
}
}
margin-left: 5px;
min-height: 200px;
}
&-group-title {
position: absolute;
font-size: 0.85em;
width: 100%;
margin: 0;
height: 24px;
display: flex;
align-items: end;
&.disabled {
display: none;
}
}
&-item {
position: absolute;
width: 32px;
height: 32px;
box-sizing: border-box;
display: flex;
font-size: 32px;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
img {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
}
}

View File

@ -0,0 +1,48 @@
<template>
<div
ref="container"
class="emoji-grid"
:class="scrolledClass"
@scroll.passive="onScroll"
>
<div
:style="{
height: `${gridHeight}px`,
}"
>
<template v-for="item in visibleItems">
<h6
v-if="'title' in item && item.title.length"
:key="'title-' + item.id"
class="emoji-group-title"
:style="{
top: item.position.y + 'px',
left: item.position.x + 'px'
}"
>
{{ item.title }}
</h6>
<span
v-else-if="'emoji' in item"
:key="'emoji-' + item.id"
class="emoji-item"
:title="item.emoji.displayText"
:style="{
top: item.position.y + 'px',
left: item.position.x + 'px'
}"
@click.stop.prevent="onEmoji(item.emoji)"
>
<span v-if="!item.emoji.imageUrl">{{ item.emoji.replacement }}</span>
<img
v-else
:src="item.emoji.imageUrl"
>
</span>
</template>
</div>
</div>
</template>
<script src="./emoji_grid.js"></script>
<style lang="scss" src="./emoji_grid.scss"></style>

View File

@ -205,7 +205,6 @@ const EmojiInput = {
}, },
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => { this.$nextTick(() => {
this.scrollIntoView() this.scrollIntoView()
this.focusPickerInput() this.focusPickerInput()
@ -223,7 +222,6 @@ const EmojiInput = {
this.showPicker = !this.showPicker this.showPicker = !this.showPicker
if (this.showPicker) { if (this.showPicker) {
this.scrollIntoView() this.scrollIntoView()
this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput) this.$nextTick(this.focusPickerInput)
} }
}, },

View File

@ -1,5 +1,6 @@
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import EmojiGrid from '../emoji_grid/emoji_grid.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faBoxOpen, faBoxOpen,
@ -14,13 +15,6 @@ library.add(
faSmileBeam faSmileBeam
) )
// At widest, approximately 20 emoji are visible in a row,
// loading 3 rows, could be overkill for narrow picker
const LOAD_EMOJI_BY = 60
// When to start loading new batch emoji, in pixels
const LOAD_EMOJI_MARGIN = 64
const EmojiPicker = { const EmojiPicker = {
props: { props: {
enableStickerPicker: { enableStickerPicker: {
@ -39,16 +33,13 @@ const EmojiPicker = {
keyword: '', keyword: '',
activeGroup: 'standard', activeGroup: 'standard',
showingStickers: false, showingStickers: false,
groupsScrolledClass: 'scrolled-top', keepOpen: false
keepOpen: false,
customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
customEmojiLoadAllConfirmed: false
} }
}, },
components: { components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox Checkbox,
EmojiGrid
}, },
methods: { methods: {
onStickerUploaded (e) { onStickerUploaded (e) {
@ -61,12 +52,6 @@ const EmojiPicker = {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
}, },
onScroll (e) {
const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target)
this.scrolledGroup(target)
this.triggerLoadMore(target)
},
onWheel (e) { onWheel (e) {
e.preventDefault() e.preventDefault()
this.$refs['emoji-tabs'].scrollBy(e.deltaY, 0) this.$refs['emoji-tabs'].scrollBy(e.deltaY, 0)
@ -74,68 +59,12 @@ const EmojiPicker = {
highlight (key) { highlight (key) {
this.setShowStickers(false) this.setShowStickers(false)
this.activeGroup = key this.activeGroup = key
}, if (this.keyword.length) {
updateScrolledClass (target) { this.$refs.emojiGrid.scrollToItem(key)
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
} }
}, },
triggerLoadMore (target) { onActiveGroup (group) {
const ref = this.$refs['group-end-custom'] this.activeGroup = group
if (!ref) return
const bottom = ref.offsetTop + ref.offsetHeight
const scrollerBottom = target.scrollTop + target.clientHeight
const scrollerTop = target.scrollTop
const scrollerMax = target.scrollHeight
// Loads more emoji when they come into view
const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
// Always load when at the very top in case there's no scroll space yet
const atTop = scrollerTop < 5
// Don't load when looking at unicode category or at the very bottom
const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
if (!bottomAboveViewport && (approachingBottom || atTop)) {
this.loadEmoji()
}
},
scrolledGroup (target) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojisView.forEach(group => {
const ref = this.$refs['group-' + group.id]
if (ref.offsetTop <= top) {
this.activeGroup = group.id
}
})
})
},
loadEmoji () {
const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
if (allLoaded) {
return
}
this.customEmojiBufferSlice += LOAD_EMOJI_BY
},
startEmojiLoad (forceUpdate = false) {
if (!forceUpdate) {
this.keyword = ''
}
this.$nextTick(() => {
this.$refs['emoji-groups'].scrollTop = 0
})
const bufferSize = this.customEmojiBuffer.length
const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
if (bufferPrefilledAll && !forceUpdate) {
return
}
this.customEmojiBufferSlice = LOAD_EMOJI_BY
}, },
toggleStickers () { toggleStickers () {
this.showingStickers = !this.showingStickers this.showingStickers = !this.showingStickers
@ -151,13 +80,6 @@ const EmojiPicker = {
}) })
} }
}, },
watch: {
keyword () {
this.customEmojiLoadAllConfirmed = false
this.onScroll()
this.startEmojiLoad(true)
}
},
computed: { computed: {
activeGroupView () { activeGroupView () {
return this.showingStickers ? '' : this.activeGroup return this.showingStickers ? '' : this.activeGroup
@ -173,9 +95,6 @@ const EmojiPicker = {
this.$store.state.instance.customEmoji || [] this.$store.state.instance.customEmoji || []
) )
}, },
customEmojiBuffer () {
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
},
emojis () { emojis () {
const standardEmojis = this.$store.state.instance.emoji || [] const standardEmojis = this.$store.state.instance.emoji || []
const customEmojis = this.sortedEmoji const customEmojis = this.sortedEmoji

View File

@ -85,10 +85,6 @@
flex-grow: 1; flex-grow: 1;
} }
.emoji-groups {
min-height: 200px;
}
.additional-tabs { .additional-tabs {
border-left: 1px solid; border-left: 1px solid;
border-left-color: $fallback--icon; border-left-color: $fallback--icon;
@ -167,76 +163,12 @@
} }
} }
.emoji { .emoji-search {
&-search { padding: 5px;
padding: 5px; flex: 0 0 auto;
flex: 0 0 auto;
input { input {
width: 100%; width: 100%;
}
} }
&-groups {
flex: 1 1 1px;
position: relative;
overflow: auto;
user-select: none;
mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
linear-gradient(to top, white, white);
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
-webkit-mask-composite: xor;
mask-composite: exclude;
&.scrolled {
&-top {
mask-size: 100% 20px, 100% 0, auto;
}
&-bottom {
mask-size: 100% 0, 100% 20px, auto;
}
}
}
&-group {
display: flex;
align-items: center;
flex-wrap: wrap;
padding-left: 5px;
justify-content: left;
&-title {
font-size: 0.85em;
width: 100%;
margin: 0;
&.disabled {
display: none;
}
}
}
&-item {
width: 32px;
height: 32px;
box-sizing: border-box;
display: flex;
font-size: 32px;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
img {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
}
} }
} }

View File

@ -2,9 +2,9 @@
<div class="emoji-picker panel panel-default panel-body"> <div class="emoji-picker panel panel-default panel-body">
<div class="heading"> <div class="heading">
<span <span
ref="emoji-tabs"
class="emoji-tabs" class="emoji-tabs"
@wheel="onWheel" @wheel="onWheel"
ref="emoji-tabs"
> >
<span <span
v-for="group in emojis" v-for="group in emojis"
@ -51,39 +51,12 @@
@input="$event.target.composing = false" @input="$event.target.composing = false"
> >
</div> </div>
<div <EmojiGrid
ref="emoji-groups" ref="emojiGrid"
class="emoji-groups" :groups="emojisView"
:class="groupsScrolledClass" @emoji="onEmoji"
@scroll="onScroll" @active-group="onActiveGroup"
> />
<div
v-for="group in emojisView"
:key="group.id"
class="emoji-group"
>
<h6
:ref="'group-' + group.id"
class="emoji-group-title"
>
{{ group.text }}
</h6>
<span
v-for="emoji in group.emojis"
:key="group.id + emoji.displayText"
:title="emoji.displayText"
class="emoji-item"
@click.stop.prevent="onEmoji(emoji)"
>
<span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
<img
v-else
:src="emoji.imageUrl"
>
</span>
<span :ref="'group-end-' + group.id" />
</div>
</div>
<div <div
v-if="showKeepOpen" v-if="showKeepOpen"
class="keep-open" class="keep-open"

View File

@ -43,6 +43,7 @@ const FollowRequestCard = {
doApprove () { doApprove () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user) this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('decrementFollowRequestsCount')
const notifId = this.findFollowRequestNotificationId() const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId }) this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
@ -66,6 +67,7 @@ const FollowRequestCard = {
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => { .then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId }) this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('decrementFollowRequestsCount')
this.$store.dispatch('removeFollowRequest', this.user) this.$store.dispatch('removeFollowRequest', this.user)
}) })
this.hideDenyConfirmDialog() this.hideDenyConfirmDialog()
@ -80,6 +82,11 @@ const FollowRequestCard = {
}, },
shouldConfirmDeny () { shouldConfirmDeny () {
return this.mergedConfig.modalOnDenyFollow return this.mergedConfig.modalOnDenyFollow
},
show () {
const notifId = this.$store.state.api.followRequests.find(req => req.id === this.user.id)
return notifId !== undefined
} }
} }
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<basic-user-card :user="user"> <basic-user-card :user="user" v-if="show">
<div class="follow-request-card-content-container"> <div class="follow-request-card-content-container">
<button <button
class="btn button-default" class="btn button-default"

View File

@ -1,10 +1,26 @@
import FollowRequestCard from '../follow_request_card/follow_request_card.vue' import FollowRequestCard from '../follow_request_card/follow_request_card.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import List from '../list/list.vue'
import get from 'lodash/get'
const FollowRequestList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchFollowRequests'),
select: (props, $store) => get($store.state.api, 'followRequests', []).map(req => $store.getters.findUser(req.id)),
destroy: (props, $store) => $store.dispatch('clearFollowRequests'),
childPropName: 'items',
additionalPropNames: ['userId']
})(List);
const FollowRequests = { const FollowRequests = {
components: { components: {
FollowRequestCard FollowRequestCard,
FollowRequestList
}, },
computed: { computed: {
userId () {
return this.$store.state.users.currentUser.id
},
requests () { requests () {
return this.$store.state.api.followRequests return this.$store.state.api.followRequests
} }

View File

@ -6,12 +6,11 @@
</div> </div>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<FollowRequestCard <FollowRequestList :user-id="userId">
v-for="request in requests" <template #item="{item}">
:key="request.id" <FollowRequestCard :user="item" />
:user="request" </template>
class="list-item" </FollowRequestList>
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -75,7 +75,7 @@ const LoginForm = {
} }
return return
} }
this.login(result).then(() => { this.login({ username: this.user.username, ...result }).then(() => {
this.$router.push({ name: 'friends' }) this.$router.push({ name: 'friends' })
}) })
}) })

View File

@ -33,11 +33,6 @@ library.add(
) )
const NavPanel = { const NavPanel = {
created () {
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: { components: {
TimelineMenuContent TimelineMenuContent
}, },
@ -54,11 +49,13 @@ const NavPanel = {
computed: { computed: {
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private, privateMode: state => state.instance.private,
federating: state => state.instance.federating federating: state => state.instance.federating
}), }),
...mapGetters(['unreadAnnouncementCount']) ...mapGetters(['unreadAnnouncementCount']),
followRequestCount () {
return this.$store.state.users.currentUser.follow_requests_count
}
} }
} }

View File

@ -6,6 +6,7 @@ const oac = {
if (this.code) { if (this.code) {
const { clientId, clientSecret } = this.$store.state.oauth const { clientId, clientSecret } = this.$store.state.oauth
// XXX look at this later
oauth.getToken({ oauth.getToken({
clientId, clientId,
clientSecret, clientSecret,

View File

@ -69,9 +69,16 @@ const SettingsModal = {
this.$store.dispatch('closeSettingsModal') this.$store.dispatch('closeSettingsModal')
}, },
logout () { logout () {
this.$router.replace('/main/public')
this.$store.dispatch('closeSettingsModal') this.$store.dispatch('closeSettingsModal')
this.$router.replace('/main/public')
this.$store.dispatch('logout') this.$store.dispatch('logout')
.then(() => {
// check if logged in to other accounts
const accounts = Object.keys(this.$store.state.oauth.userToken)
if (accounts !== []) {
this.$store.dispatch('loginUser', this.$store.state.oauth.userToken[accounts[0]])
}
})
}, },
peekModal () { peekModal () {
this.$store.dispatch('togglePeekSettingsModal') this.$store.dispatch('togglePeekSettingsModal')

View File

@ -7,6 +7,7 @@ import AccountActions from '../account_actions/account_actions.vue'
import Select from '../select/select.vue' import Select from '../select/select.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import ConfirmModal from '../confirm_modal/confirm_modal.vue' import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import AccountSwitcher from '../account_switcher/account_switcher.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
@ -15,7 +16,8 @@ import {
faRss, faRss,
faSearchPlus, faSearchPlus,
faExternalLinkAlt, faExternalLinkAlt,
faEdit faEdit,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -23,7 +25,8 @@ library.add(
faBell, faBell,
faSearchPlus, faSearchPlus,
faExternalLinkAlt, faExternalLinkAlt,
faEdit faEdit,
faUserPlus,
) )
export default { export default {
@ -128,7 +131,8 @@ export default {
FollowButton, FollowButton,
Select, Select,
RichContent, RichContent,
ConfirmModal ConfirmModal,
AccountSwitcher
}, },
methods: { methods: {
refetchRelationship () { refetchRelationship () {

View File

@ -110,7 +110,7 @@
min-width: 0; min-width: 0;
} }
.Avatar { a .Avatar {
--_avatarShadowBox: var(--avatarShadow); --_avatarShadowBox: var(--avatarShadow);
--_avatarShadowFilter: var(--avatarShadowFilter); --_avatarShadowFilter: var(--avatarShadowFilter);
--_avatarShadowInset: var(--avatarShadowInset); --_avatarShadowInset: var(--avatarShadowInset);
@ -151,7 +151,7 @@
} }
} }
.external-link-button, .edit-profile-button { .external-link-button, .edit-profile-button, .switch-account-button {
cursor: pointer; cursor: pointer;
width: 2.5em; width: 2.5em;
text-align: center; text-align: center;
@ -235,7 +235,7 @@
line-height: 22px; line-height: 22px;
flex-wrap: wrap; flex-wrap: wrap;
.following, .requested_by { .following, .requested_by, .blocking {
flex: 1 0 auto; flex: 1 0 auto;
margin: 0; margin: 0;
margin-bottom: .25em; margin-bottom: .25em;

View File

@ -56,6 +56,7 @@
:title="$t('user_card.edit_profile')" :title="$t('user_card.edit_profile')"
/> />
</button> </button>
<AccountSwitcher v-if="!isOtherUser && user.is_local" />
<a <a
v-if="isOtherUser && !user.is_local" v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url" :href="user.statusnet_profile_url"
@ -127,6 +128,12 @@
</div> </div>
</div> </div>
<div class="user-meta"> <div class="user-meta">
<div
v-if="relationship.blocked_by && loggedIn && isOtherUser"
class="blocking"
>
{{ $t('user_card.blocks_you') }}
</div>
<div <div
v-if="relationship.followed_by && loggedIn && isOtherUser" v-if="relationship.followed_by && loggedIn && isOtherUser"
class="following" class="following"
@ -187,6 +194,7 @@
<FollowButton <FollowButton
:relationship="relationship" :relationship="relationship"
:user="user" :user="user"
:disabled="relationship.blocked_by"
/> />
<template v-if="relationship.following"> <template v-if="relationship.following">
<ProgressButton <ProgressButton

View File

@ -224,7 +224,7 @@ const UserProfile = {
TabSwitcher, TabSwitcher,
Conversation, Conversation,
RichContent, RichContent,
FollowedTagList, FollowedTagList
} }
} }

View File

@ -1123,6 +1123,7 @@
"block_confirm_title": "Block user", "block_confirm_title": "Block user",
"block_progress": "Blocking…", "block_progress": "Blocking…",
"blocked": "Blocked!", "blocked": "Blocked!",
"blocks_you": "Blocks you!",
"bot": "Bot", "bot": "Bot",
"deactivated": "Deactivated", "deactivated": "Deactivated",
"deny": "Deny", "deny": "Deny",

View File

@ -1,5 +1,6 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { WSConnectionStatus } from '../services/api/api.service.js' import { WSConnectionStatus } from '../services/api/api.service.js'
import { map } from 'lodash'
const retryTimeout = (multiplier) => 1000 * multiplier const retryTimeout = (multiplier) => 1000 * multiplier
@ -40,9 +41,6 @@ const api = {
setSocket (state, socket) { setSocket (state, socket) {
state.socket = socket state.socket = socket
}, },
setFollowRequests (state, value) {
state.followRequests = value
},
setMastoUserSocketStatus (state, value) { setMastoUserSocketStatus (state, value) {
state.mastoUserSocketStatus = value state.mastoUserSocketStatus = value
}, },
@ -51,6 +49,15 @@ const api = {
}, },
resetRetryMultiplier (state) { resetRetryMultiplier (state) {
state.retryMultiplier = 1 state.retryMultiplier = 1
},
setFollowRequests (state, value) {
state.followRequests = [...value]
},
saveFollowRequests (state, requests) {
state.followRequests = [...state.followRequests, ...requests]
},
saveFollowRequestPagination (state, pagination) {
state.followRequestsPagination = pagination
} }
}, },
actions: { actions: {
@ -240,24 +247,22 @@ const api = {
...rest ...rest
}) })
}, },
// Follow requests
startFetchingFollowRequests (store) {
if (store.state.fetchers['followRequests']) return
const fetcher = store.state.backendInteractor.startFetchingFollowRequests({ store })
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
},
stopFetchingFollowRequests (store) {
const fetcher = store.state.fetchers.followRequests
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
},
removeFollowRequest (store, request) { removeFollowRequest (store, request) {
let requests = store.state.followRequests.filter((it) => it !== request) let requests = [...store.state.followRequests].filter((it) => it.id !== request.id)
store.commit('setFollowRequests', requests) store.commit('setFollowRequests', requests)
}, },
fetchFollowRequests ({ rootState, commit }) {
const pagination = rootState.api.followRequestsPagination
return rootState.api.backendInteractor.getFollowRequests({ pagination })
.then((requests) => {
if (requests.data.length > 0) {
commit('addNewUsers', requests.data)
commit('saveFollowRequests', requests.data)
commit('saveFollowRequestPagination', requests.pagination)
}
return requests
})
},
// Lists // Lists
startFetchingLists (store) { startFetchingLists (store) {
if (store.state.fetchers['lists']) return if (store.state.fetchers['lists']) return

View File

@ -68,8 +68,8 @@ const mutations = {
// actions // actions
const actions = { const actions = {
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
async login ({ state, dispatch, commit }, { access_token }) { async login ({ state, dispatch, commit }, { access_token, username }) {
commit('setToken', access_token, { root: true }) commit('setToken', { token: access_token, username }, { root: true })
await dispatch('loginUser', access_token, { root: true }) await dispatch('loginUser', access_token, { root: true })
resetState(state) resetState(state)
} }

View File

@ -10,7 +10,7 @@ const oauth = {
/* User token is authentication for app with user, this is for every calls /* User token is authentication for app with user, this is for every calls
* that need authorized user to be successful (i.e. posting, liking etc) * that need authorized user to be successful (i.e. posting, liking etc)
*/ */
userToken: false userToken: {}
}, },
mutations: { mutations: {
setClientData (state, { clientId, clientSecret }) { setClientData (state, { clientId, clientSecret }) {
@ -20,11 +20,11 @@ const oauth = {
setAppToken (state, token) { setAppToken (state, token) {
state.appToken = token state.appToken = token
}, },
setToken (state, token) { setToken (state, { token, username }) {
state.userToken = token state.userToken[username] = token
}, },
clearToken (state) { clearToken (state, username) {
state.userToken = false delete state.userToken[username]
// state.token is userToken with older name, coming from persistent state // state.token is userToken with older name, coming from persistent state
// let's clear it as well, since it is being used as a fallback of state.userToken // let's clear it as well, since it is being used as a fallback of state.userToken
delete state.token delete state.token

View File

@ -149,6 +149,12 @@ export const mutations = {
endLogin (state) { endLogin (state) {
state.loggingIn = false state.loggingIn = false
}, },
beginAccountSwitch (state) {
state.switchingAccounts = true
},
endAccountSwitch (state) {
state.switchingAccounts = false
},
saveFriendIds (state, { id, friendIds }) { saveFriendIds (state, { id, friendIds }) {
const user = state.usersObject[id] const user = state.usersObject[id]
user.friendIds = uniq(concat(user.friendIds || [], friendIds)) user.friendIds = uniq(concat(user.friendIds || [], friendIds))
@ -265,6 +271,12 @@ export const mutations = {
signUpFailure (state, errors) { signUpFailure (state, errors) {
state.signUpPending = false state.signUpPending = false
state.signUpErrors = errors state.signUpErrors = errors
},
decrementFollowRequestsCount (store) {
store.currentUser.follow_requests_count--
},
incrementFollowRequestsCount (store) {
store.currentUser.follow_requests_count++
} }
} }
@ -290,6 +302,7 @@ export const getters = {
export const defaultState = { export const defaultState = {
loggingIn: false, loggingIn: false,
switchingAccounts: false,
lastLoginName: false, lastLoginName: false,
currentUser: false, currentUser: false,
users: [], users: [],
@ -504,6 +517,12 @@ const users = {
store.commit('setUserForNotification', notification) store.commit('setUserForNotification', notification)
}) })
}, },
decrementFollowRequestsCount (store) {
store.commit('decrementFollowRequestsCount')
},
incrementFollowRequestsCount (store) {
store.commit('incrementFollowRequestsCount')
},
searchUsers ({ rootState, commit }, { query }) { searchUsers ({ rootState, commit }, { query }) {
return rootState.api.backendInteractor.searchUsers({ query }) return rootState.api.backendInteractor.searchUsers({ query })
.then((users) => { .then((users) => {
@ -525,7 +544,7 @@ const users = {
return data return data
} else if (data.me !== undefined) { } else if (data.me !== undefined) {
store.commit('signUpSuccess') store.commit('signUpSuccess')
store.commit('setToken', data.access_token) store.commit('setToken', { token: data.access_token, username: userInfo.nickname })
store.dispatch('loginUser', data.access_token) store.dispatch('loginUser', data.access_token)
return data return data
} else { } else {
@ -542,12 +561,13 @@ const users = {
}, },
logout (store) { logout (store) {
const { oauth, instance } = store.rootState const { oauth, instance, users } = store.rootState
const data = { const data = {
...oauth, ...oauth,
commit: store.commit, commit: store.commit,
instance: instance.server instance: instance.server,
username: users.currentUser.screen_name
} }
return oauthApi.getOrCreateApp(data) return oauthApi.getOrCreateApp(data)
@ -563,11 +583,10 @@ const users = {
.then(() => { .then(() => {
store.commit('clearCurrentUser') store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket') store.dispatch('disconnectFromSocket')
store.commit('clearToken') store.commit('clearToken', data.username)
store.dispatch('stopFetchingTimeline', 'friends') store.dispatch('stopFetchingTimeline', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.dispatch('stopFetchingNotifications') store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingFollowRequests')
store.dispatch('stopFetchingConfig') store.dispatch('stopFetchingConfig')
store.commit('clearNotifications') store.commit('clearNotifications')
store.commit('resetStatuses') store.commit('resetStatuses')
@ -583,6 +602,19 @@ const users = {
store.rootState.api.backendInteractor.verifyCredentials(accessToken) store.rootState.api.backendInteractor.verifyCredentials(accessToken)
.then((data) => { .then((data) => {
if (!data.error) { if (!data.error) {
// clear old user
// TODO: maybe we don't need some of this
if (store.state.loggingIn) {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.dispatch('stopFetchingTimeline', 'friends')
store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingConfig')
store.commit('clearNotifications')
store.commit('resetStatuses')
store.commit('endAccountSwitch')
}
const user = data const user = data
// user.credentials = userCredentials // user.credentials = userCredentials
user.credentials = accessToken user.credentials = accessToken

View File

@ -406,14 +406,6 @@ const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
.then((data) => data.json()) .then((data) => data.json())
.then((data) => data.map(parseUser)) .then((data) => data.map(parseUser))
} }
const fetchFollowRequests = ({ credentials }) => {
const url = MASTODON_FOLLOW_REQUESTS_URL
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => data.map(parseUser))
}
const fetchLists = ({ credentials }) => { const fetchLists = ({ credentials }) => {
const url = MASTODON_LISTS_URL const url = MASTODON_LISTS_URL
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
@ -1601,6 +1593,26 @@ const getFollowedHashtags = ({ credentials, pagination: savedPagination }) => {
}); });
} }
const getFollowRequests = ({ credentials, pagination: savedPagination }) => {
const queryParams = new URLSearchParams()
if (savedPagination?.maxId) {
queryParams.append('max_id', savedPagination.maxId)
}
const url = `${MASTODON_FOLLOW_REQUESTS_URL}?${queryParams.toString()}`
let pagination = {};
return fetch(url, {
credentials
}).then((data) => {
pagination = parseLinkHeaderPagination(data.headers.get('Link'), { flakeId: true });
return data.json()
}).then((data) => {
return {
pagination,
data: data.map(parseUser)
}
});
}
export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => { export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
return Object.entries({ return Object.entries({
...(credentials ...(credentials
@ -1790,7 +1802,6 @@ const apiService = {
mfaConfirmOTP, mfaConfirmOTP,
addBackup, addBackup,
listBackups, listBackups,
fetchFollowRequests,
fetchLists, fetchLists,
createList, createList,
getList, getList,
@ -1841,6 +1852,7 @@ const apiService = {
followHashtag, followHashtag,
unfollowHashtag, unfollowHashtag,
getFollowedHashtags, getFollowedHashtags,
getFollowRequests
} }
export default apiService export default apiService

View File

@ -1,7 +1,6 @@
import apiService, { getMastodonSocketURI, ProcessedWS } from '../api/api.service.js' import apiService, { getMastodonSocketURI, ProcessedWS } from '../api/api.service.js'
import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js' import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js'
import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js' import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js' import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
import announcementsFetcher from '../../services/announcements_fetcher/announcements_fetcher.service.js' import announcementsFetcher from '../../services/announcements_fetcher/announcements_fetcher.service.js'
import configFetcher from '../config_fetcher/config_fetcher.service.js' import configFetcher from '../config_fetcher/config_fetcher.service.js'
@ -28,10 +27,6 @@ const backendInteractorService = credentials => ({
return notificationsFetcher.fetchAndUpdate({ ...args, credentials }) return notificationsFetcher.fetchAndUpdate({ ...args, credentials })
}, },
startFetchingFollowRequests ({ store }) {
return followRequestFetcher.startFetching({ store, credentials })
},
startFetchingLists ({ store }) { startFetchingLists ({ store }) {
return listsFetcher.startFetching({ store, credentials }) return listsFetcher.startFetching({ store, credentials })
}, },

View File

@ -90,6 +90,7 @@ export const parseUser = (data) => {
output.friends_count = data.following_count output.friends_count = data.following_count
output.bot = data.bot output.bot = data.bot
output.follow_requests_count = data.follow_requests_count
if (data.akkoma) { if (data.akkoma) {
output.instance = data.akkoma.instance output.instance = data.akkoma.instance
output.status_ttl_days = data.akkoma.status_ttl_days output.status_ttl_days = data.akkoma.status_ttl_days

View File

@ -1,23 +0,0 @@
import apiService from '../api/api.service.js'
import { promiseInterval } from '../promise_interval/promise_interval.js'
const fetchAndUpdate = ({ store, credentials }) => {
return apiService.fetchFollowRequests({ credentials })
.then((requests) => {
store.commit('setFollowRequests', requests)
store.commit('addNewUsers', requests)
}, () => {})
.catch(() => {})
}
const startFetching = ({ credentials, store }) => {
const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
boundFetchAndUpdate()
return promiseInterval(boundFetchAndUpdate, 240000)
}
const followRequestFetcher = {
startFetching
}
export default followRequestFetcher