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) => {
if (store.getters.getUserToken()) {
try {
await store.dispatch('loginUser', store.getters.getUserToken())
await store.dispatch('loginUser', store.getters.getUserToken()[store.state.users.lastLoginName])
} catch (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 () {
this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
this.scrollIntoView()
this.focusPickerInput()
@ -223,7 +222,6 @@ const EmojiInput = {
this.showPicker = !this.showPicker
if (this.showPicker) {
this.scrollIntoView()
this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput)
}
},

View File

@ -1,5 +1,6 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
import EmojiGrid from '../emoji_grid/emoji_grid.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
@ -14,13 +15,6 @@ library.add(
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 = {
props: {
enableStickerPicker: {
@ -39,16 +33,13 @@ const EmojiPicker = {
keyword: '',
activeGroup: 'standard',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
customEmojiLoadAllConfirmed: false
keepOpen: false
}
},
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox
Checkbox,
EmojiGrid
},
methods: {
onStickerUploaded (e) {
@ -61,12 +52,6 @@ const EmojiPicker = {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
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) {
e.preventDefault()
this.$refs['emoji-tabs'].scrollBy(e.deltaY, 0)
@ -74,68 +59,12 @@ const EmojiPicker = {
highlight (key) {
this.setShowStickers(false)
this.activeGroup = key
},
updateScrolledClass (target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
if (this.keyword.length) {
this.$refs.emojiGrid.scrollToItem(key)
}
},
triggerLoadMore (target) {
const ref = this.$refs['group-end-custom']
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
onActiveGroup (group) {
this.activeGroup = group
},
toggleStickers () {
this.showingStickers = !this.showingStickers
@ -151,13 +80,6 @@ const EmojiPicker = {
})
}
},
watch: {
keyword () {
this.customEmojiLoadAllConfirmed = false
this.onScroll()
this.startEmojiLoad(true)
}
},
computed: {
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
@ -173,9 +95,6 @@ const EmojiPicker = {
this.$store.state.instance.customEmoji || []
)
},
customEmojiBuffer () {
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
},
emojis () {
const standardEmojis = this.$store.state.instance.emoji || []
const customEmojis = this.sortedEmoji

View File

@ -85,10 +85,6 @@
flex-grow: 1;
}
.emoji-groups {
min-height: 200px;
}
.additional-tabs {
border-left: 1px solid;
border-left-color: $fallback--icon;
@ -167,76 +163,12 @@
}
}
.emoji {
&-search {
padding: 5px;
flex: 0 0 auto;
.emoji-search {
padding: 5px;
flex: 0 0 auto;
input {
width: 100%;
}
input {
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="heading">
<span
ref="emoji-tabs"
class="emoji-tabs"
@wheel="onWheel"
ref="emoji-tabs"
>
<span
v-for="group in emojis"
@ -51,39 +51,12 @@
@input="$event.target.composing = false"
>
</div>
<div
ref="emoji-groups"
class="emoji-groups"
:class="groupsScrolledClass"
@scroll="onScroll"
>
<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>
<EmojiGrid
ref="emojiGrid"
:groups="emojisView"
@emoji="onEmoji"
@active-group="onActiveGroup"
/>
<div
v-if="showKeepOpen"
class="keep-open"

View File

@ -43,6 +43,7 @@ const FollowRequestCard = {
doApprove () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('decrementFollowRequestsCount')
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
@ -66,6 +67,7 @@ const FollowRequestCard = {
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('decrementFollowRequestsCount')
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
@ -80,6 +82,11 @@ const FollowRequestCard = {
},
shouldConfirmDeny () {
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>
<basic-user-card :user="user">
<basic-user-card :user="user" v-if="show">
<div class="follow-request-card-content-container">
<button
class="btn button-default"

View File

@ -1,10 +1,26 @@
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 = {
components: {
FollowRequestCard
FollowRequestCard,
FollowRequestList
},
computed: {
userId () {
return this.$store.state.users.currentUser.id
},
requests () {
return this.$store.state.api.followRequests
}

View File

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

View File

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

View File

@ -33,11 +33,6 @@ library.add(
)
const NavPanel = {
created () {
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: {
TimelineMenuContent
},
@ -54,11 +49,13 @@ const NavPanel = {
computed: {
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
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) {
const { clientId, clientSecret } = this.$store.state.oauth
// XXX look at this later
oauth.getToken({
clientId,
clientSecret,

View File

@ -69,9 +69,16 @@ const SettingsModal = {
this.$store.dispatch('closeSettingsModal')
},
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('closeSettingsModal')
this.$router.replace('/main/public')
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 () {
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 RichContent from 'src/components/rich_content/rich_content.jsx'
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 { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -15,7 +16,8 @@ import {
faRss,
faSearchPlus,
faExternalLinkAlt,
faEdit
faEdit,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
@ -23,7 +25,8 @@ library.add(
faBell,
faSearchPlus,
faExternalLinkAlt,
faEdit
faEdit,
faUserPlus,
)
export default {
@ -128,7 +131,8 @@ export default {
FollowButton,
Select,
RichContent,
ConfirmModal
ConfirmModal,
AccountSwitcher
},
methods: {
refetchRelationship () {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { WSConnectionStatus } from '../services/api/api.service.js'
import { map } from 'lodash'
const retryTimeout = (multiplier) => 1000 * multiplier
@ -40,9 +41,6 @@ const api = {
setSocket (state, socket) {
state.socket = socket
},
setFollowRequests (state, value) {
state.followRequests = value
},
setMastoUserSocketStatus (state, value) {
state.mastoUserSocketStatus = value
},
@ -51,6 +49,15 @@ const api = {
},
resetRetryMultiplier (state) {
state.retryMultiplier = 1
},
setFollowRequests (state, value) {
state.followRequests = [...value]
},
saveFollowRequests (state, requests) {
state.followRequests = [...state.followRequests, ...requests]
},
saveFollowRequestPagination (state, pagination) {
state.followRequestsPagination = pagination
}
},
actions: {
@ -240,24 +247,22 @@ const api = {
...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) {
let requests = store.state.followRequests.filter((it) => it !== request)
let requests = [...store.state.followRequests].filter((it) => it.id !== request.id)
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
startFetchingLists (store) {
if (store.state.fetchers['lists']) return

View File

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

View File

@ -10,7 +10,7 @@ const oauth = {
/* 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)
*/
userToken: false
userToken: {}
},
mutations: {
setClientData (state, { clientId, clientSecret }) {
@ -20,11 +20,11 @@ const oauth = {
setAppToken (state, token) {
state.appToken = token
},
setToken (state, token) {
state.userToken = token
setToken (state, { token, username }) {
state.userToken[username] = token
},
clearToken (state) {
state.userToken = false
clearToken (state, username) {
delete state.userToken[username]
// 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
delete state.token

View File

@ -149,6 +149,12 @@ export const mutations = {
endLogin (state) {
state.loggingIn = false
},
beginAccountSwitch (state) {
state.switchingAccounts = true
},
endAccountSwitch (state) {
state.switchingAccounts = false
},
saveFriendIds (state, { id, friendIds }) {
const user = state.usersObject[id]
user.friendIds = uniq(concat(user.friendIds || [], friendIds))
@ -265,6 +271,12 @@ export const mutations = {
signUpFailure (state, errors) {
state.signUpPending = false
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 = {
loggingIn: false,
switchingAccounts: false,
lastLoginName: false,
currentUser: false,
users: [],
@ -504,6 +517,12 @@ const users = {
store.commit('setUserForNotification', notification)
})
},
decrementFollowRequestsCount (store) {
store.commit('decrementFollowRequestsCount')
},
incrementFollowRequestsCount (store) {
store.commit('incrementFollowRequestsCount')
},
searchUsers ({ rootState, commit }, { query }) {
return rootState.api.backendInteractor.searchUsers({ query })
.then((users) => {
@ -525,7 +544,7 @@ const users = {
return data
} else if (data.me !== undefined) {
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)
return data
} else {
@ -542,12 +561,13 @@ const users = {
},
logout (store) {
const { oauth, instance } = store.rootState
const { oauth, instance, users } = store.rootState
const data = {
...oauth,
commit: store.commit,
instance: instance.server
instance: instance.server,
username: users.currentUser.screen_name
}
return oauthApi.getOrCreateApp(data)
@ -563,11 +583,10 @@ const users = {
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.commit('clearToken')
store.commit('clearToken', data.username)
store.dispatch('stopFetchingTimeline', 'friends')
store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
store.dispatch('stopFetchingNotifications')
store.dispatch('stopFetchingFollowRequests')
store.dispatch('stopFetchingConfig')
store.commit('clearNotifications')
store.commit('resetStatuses')
@ -583,6 +602,19 @@ const users = {
store.rootState.api.backendInteractor.verifyCredentials(accessToken)
.then((data) => {
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
// user.credentials = userCredentials
user.credentials = accessToken

View File

@ -406,14 +406,6 @@ const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
.then((data) => data.json())
.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 url = MASTODON_LISTS_URL
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 = {} }) => {
return Object.entries({
...(credentials
@ -1790,7 +1802,6 @@ const apiService = {
mfaConfirmOTP,
addBackup,
listBackups,
fetchFollowRequests,
fetchLists,
createList,
getList,
@ -1841,6 +1852,7 @@ const apiService = {
followHashtag,
unfollowHashtag,
getFollowedHashtags,
getFollowRequests
}
export default apiService

View File

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

View File

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