Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
109 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 5036b1b807..f3e3ff1542 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,583 +1,583 @@
import { uniqBy } from 'lodash'
import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useScrobblesStore } from 'src/stores/scrobbles.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
statusId: String,
statusoid: Object,
replies: Array,
expandable: Boolean,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inlineExpanded: Boolean,
showPinned: Boolean,
inProfile: Boolean,
inConversation: Boolean,
inQuote: Boolean,
profileUserId: String,
simpleTree: Boolean,
showOtherRepliesAsButton: Boolean,
canDive: Boolean,
ignoreMute: Boolean,
threadDisplayStatus: String,
},
emits: ['goto', 'dive', 'toggleExpanded', 'suspendableStateChange'],
data() {
return {
replying: false,
unmuted: false,
userExpanded: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
created() {
useScrobblesStore().getLatestScrobble(this.status.user.id)
},
computed: {
status() {
return this.statusoid ?? useStatusesStore().allStatuses.get(this.statusId)
},
repeater() {
return useUsersStore().findUser(this.status.user.id)
},
user() {
return useUsersStore().findUser(this.mainStatus.user.id)
},
showReasonMutedThread() {
return (
(this.mainStatus.thread_muted ||
this.smainSatus.reblog?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
return highlightClass(this.repeater)
},
userClass() {
return highlightClass(this.user)
},
deleted() {
return this.status.deleted
},
repeaterStyle() {
const user = this.status.user
return highlightStyle(useUserHighlightStore().get(this.repeater.screen_name))
},
userStyle() {
if (this.noHeading) return
return highlightStyle(useUserHighlightStore().get(this.user.screen_name))
},
userProfileLink() {
return this.generateUserProfileLink(
this.user.id,
this.user.screen_name,
)
},
replyProfileLink() {
if (this.isReply) {
const user = useUsersStore().findUser(this.mainStatus.in_reply_to_user_id)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet() {
return !!this.status.retweeted_status
},
repeaterName() {
return this.status.user.name || this.status.user.screen_name_ui
},
repeaterHtml() {
return this.status.user.name
},
repeaterProfileLink() {
return this.generateUserProfileLink(
this.repeater.id,
this.repeater.screen_name,
)
},
mainStatus() {
if (this.retweet) {
return this.status.retweeted_status
} else {
return this.status
}
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.mainStatus.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.mainStatus.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.mainStatus.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.mainStatus.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
].filter(Boolean)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.ignoreMute) return false
if (this.status.user.id === this.currentUser?.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (this.status.user.id === this.currentUser?.id) return false
const { reblog } = this.status
const relationship = useUsersStore().relationship(this.status.user.id)
const relationshipReblog =
reblog && useUsersStore().relationship(reblog.user.id)
return (
(status.muted && !status.thread_muted) ||
// Reprööt of a muted post according to BE
(reblog?.muted && !reblog.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
relationshipReblog?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) return true
- const { reblog } = this.status
+ const { reblog } = this.mainStatus
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
- ((!reblog && status.user.id === this.profileUserId) ||
+ ((!reblog && this.mainStatus.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
reblog?.user.id === this.profileUserId)) ||
// Don't mute statuses in muted conversation when said conversation is opened
- (this.inConversation && status.thread_muted)) &&
+ (this.inConversation && this.mainStatus.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isReply() {
return !!(
this.mainStatus.in_reply_to_status_id && this.mainStatus.in_reply_to_user_id
)
},
replyToName() {
if (this.mainStatus.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(this.mainStatus.in_reply_to_user_id)
return user?.screen_name_ui
}
},
combinedFavsAndRepeatsUsers() {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.mainStatus.favoritedBy,
this.mainStatus.rebloggedBy,
)
return uniqBy(combinedUsers, 'id')
},
tags() {
return this.status.tags
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.focused &&
(this.combinedFavsAndRepeatsUsers.length > 0 ||
this.mainStatus.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return useUsersStore().currentUser
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
inThreadForest() {
return !!this.threadDisplayStatus
},
threadShowing() {
return this.threadDisplayStatus === 'showing'
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.mainStatus.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.status.quote_id
},
quoteUrl() {
return this.status.quote_url
},
quoteVisible() {
return this.status.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
showError(error) {
this.error = error
},
clearError() {
this.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
gotoOriginal(id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded() {
this.$emit('toggleExpanded')
},
toggleMute() {
this.unmuted = !this.unmuted
},
toggleUserExpanded() {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay() {
this.controlledToggleThreadDisplay()
},
scrollIfFocused(focused) {
if (this.$el.getBoundingClientRect == null) return
if (focused) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= window.innerHeight - 50) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
},
watch: {
focused: function (id) {
this.scrollIfFocused(id)
},
'mainStatus.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.focused &&
this.mainStatus.rebloggedBy &&
this.mainStatus.rebloggedBy.length !== num
) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'mainStatus.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (
this.focused &&
this.mainStatus.favoritedBy &&
this.mainStatus.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (suspend) {
this.$emit('suspendableStateChange', { id: this.status.id, suspend })
},
},
}
export default Status
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 188adfabaa..38bf1387cb 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,300 +1,290 @@
import { debounce, throttle } from 'lodash'
import { mapState } from 'pinia'
import Conversation from 'src/components/conversation/conversation.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: {
timelineRef: Object,
count: Number,
footerSlipgate: Object, // reference to an element where we should put our footer
embedded: Boolean,
inProfile: Boolean,
skipPinned: Boolean,
},
data() {
return {
showScrollTop: false,
paused: false,
unfocused: false,
- bottomedOut: false,
virtualScrollIndex: 0,
blockingClicks: false,
}
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
computed: {
timeline() {
return useTimelinesStore()[this.timelineRef.name]
},
filteredVisibleStatuses() {
return this.timeline.order
.filter((id) => this.timeline.visibleStatusIds.has(id))
.map((id) => useStatusesStore().allStatuses.get(id))
.filter(({ pinned }) => (this.skipPinned ? !pinned : true))
},
newStatusCount() {
return this.timeline.newStatusCount
},
showLoadButton() {
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
},
loadButtonString() {
if (this.timeline.flushMarker !== 0) {
return this.$t('timeline.reload')
} else {
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
mobileLoadButtonString() {
if (this.timeline.flushMarker !== 0) {
return '+'
} else {
return this.newStatusCount > 99 ? '∞' : this.newStatusCount
}
},
classes() {
let rootClasses = !this.embedded
? ['panel', 'panel-default']
: ['-embedded']
if (this.blockingClicks)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!this.embedded ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!this.embedded ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!this.embedded ? ['panel-footer'] : ['panel-body'],
),
}
},
statusesToDisplay() {
const amount = this.timeline.visibleStatusIds.size
const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
const min = Math.max(0, this.virtualScrollIndex - statusesPerSide)
const max = Math.min(amount, this.virtualScrollIndex + statusesPerSide)
return new Set(
this.timeline.order.slice(min, max),
)
},
virtualScrollingEnabled() {
return useMergedConfigStore().mergedConfig.virtualScrolling
},
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
created() {
this.timelineChange(this.timelineRef)
},
mounted() {
if (document.hidden !== undefined) {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.unfocused = document.hidden
}
window.addEventListener('keydown', this.handleShortKey)
window.addEventListener('scroll', this.handleScroll)
setTimeout(this.determineVisibleStatuses, 250)
},
unmounted() {
this.timelineChange(null, this.timelineRef)
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('keydown', this.handleShortKey)
if (document.hidden !== undefined)
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
},
methods: {
timelineChange(newTimeline, oldTimeline) {
const sameName = newTimeline?.name === oldTimeline?.name
const sameArgument = newTimeline?.argument === oldTimeline?.argument
if (sameName && sameArgument) return
if (oldTimeline) {
useTimelinesStore().deactivate(oldTimeline.name)
}
if (newTimeline) {
useTimelinesStore().activate(newTimeline.name, newTimeline.argument)
}
},
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
blockClicksTemporarily() {
if (!this.blockingClicks) {
this.blockingClicks = true
}
this.stopBlockingClicks()
},
handleShortKey(e) {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses() {
if (this.timeline.flushMarker !== 0) {
useTimelinesStore().clearTimeline(this.timelineRef.name)
useTimelinesStore().queueFlush(this.timelineRef.name, '')
this.fetchOlderStatuses()
} else {
this.blockClicksTemporarily()
useTimelinesStore().showNewStatuses(this.timelineRef.name)
this.paused = false
}
window.scrollTo({ top: 0 })
},
fetchOlderStatuses: throttle(
function () {
- this.timeline.fetcher
- .fetchAndUpdate({
- older: true,
- showImmediately: true,
- })
- .then(({ statuses }) => {
- if (statuses?.length === 0) {
- this.bottomedOut = true
- }
- })
+ this.timeline.fetcher.fetchOlder()
},
1000,
this,
),
determineVisibleStatuses() {
if (!this.$refs.timeline) return
if (!this.virtualScrollingEnabled) return
const statuses = this.$refs.timeline.children
const cappedScrollIndex = Math.max(
0,
Math.min(this.virtualScrollIndex, statuses.length - 1),
)
if (statuses.length === 0) return
const height = Math.max(document.body.offsetHeight, window.pageYOffset)
const centerOfScreen = window.pageYOffset + window.innerHeight * 0.5
// Start from approximating the index of some visible status by using the
// the center of the screen on the timeline.
let approxIndex = Math.min(
Math.floor(statuses.length * (centerOfScreen / height)),
statuses.length - 1,
)
let err = statuses[approxIndex].getBoundingClientRect().y
// if we have a previous scroll index that can be used, test if it's
- // closer than the previous approximation, use it if so
+
const virtualScrollIndexY =
statuses[cappedScrollIndex].getBoundingClientRect().y
if (Math.abs(err) > virtualScrollIndexY) {
approxIndex = cappedScrollIndex
err = virtualScrollIndexY
}
// if the status is too far from viewport, check the next/previous ones if
// they happen to be better
while (err < -20 && approxIndex < statuses.length - 1) {
err += statuses[approxIndex].offsetHeight
approxIndex++
}
while (err > window.innerHeight + 100 && approxIndex > 0) {
approxIndex--
err -= statuses[approxIndex].offsetHeight
}
// this status is now the center point for virtual scrolling and visible
// statuses will be nearby statuses before and after it
this.virtualScrollIndex = approxIndex
},
scrollLoad() {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
- this.timeline.loading === false &&
+ !this.timeline.fetcher.loading.value &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchOlderStatuses()
}
},
handleScroll: throttle(function (e) {
this.determineVisibleStatuses()
this.scrollLoad(e)
}, 200),
handleVisibilityChange() {
this.unfocused = document.hidden
},
},
watch: {
timelineRef(newTimeline, oldTimeline) {
this.timelineChange(newTimeline, oldTimeline)
},
filteredVisibleStatuses() {
this.determineVisibleStatuses()
},
newStatusCount(count) {
if (!useMergedConfigStore().mergedConfig.streaming) {
return
}
if (count > 0) {
// only 'stream' them when you're scrolled to the top
const doc = document.documentElement
const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
if (
top < 15 &&
!this.paused &&
!(
this.unfocused &&
useMergedConfigStore().mergedConfig.pauseOnUnfocused
)
) {
this.showNewStatuses()
} else {
this.paused = true
}
}
},
},
}
export default Timeline
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index bc0c506d6a..745d8c3997 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,132 +1,132 @@
<template>
<div :class="['Timeline', classes.root]">
<div
v-if="!embedded"
:class="classes.header"
>
<TimelineMenu
v-if="!embedded"
:timeline-name="timelineRef.name"
/>
<ScrollTopButton />
<template v-if="mobileLayout">
<div
v-if="showLoadButton"
class="rightside-button"
>
<button
class="button-unstyled loadmore-button"
:title="loadButtonString"
@click.prevent="showNewStatuses"
>
<FAIcon
fixed-width
icon="circle-plus"
/>
<div class="badge -counter">
{{ mobileLoadButtonString }}
</div>
</button>
</div>
<div
v-else
class="loadmore-text faint veryfaint rightside-icon"
:title="$t('timeline.up_to_date')"
:aria-disabled="true"
@click.prevent
>
<FAIcon
fixed-width
icon="check"
/>
</div>
</template>
<template v-else>
<button
v-if="showLoadButton"
class="button-default loadmore-button"
@click.prevent="showNewStatuses"
>
{{ loadButtonString }}
</button>
<div
v-else
class="loadmore-text faint"
@click.prevent
>
{{ $t('timeline.up_to_date') }}
</div>
</template>
<QuickFilterSettings
v-if="!mobileLayout"
class="rightside-button"
/>
<QuickViewSettings
class="rightside-button"
/>
</div>
<div :class="classes.body">
<div
ref="timeline"
class="timeline"
role="feed"
>
<Conversation
v-for="status in filteredVisibleStatuses"
:key="status.id"
role="listitem"
class="status-fadein"
:status-id="status.id"
:in-profile="inProfile"
:profile-user-id="timelineRef.argument"
:virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.has(status.id)"
collapsable
/>
</div>
</div>
<div :class="classes.footer">
<teleport
:to="footerSlipgate"
:disabled="!embedded || !footerSlipgate"
>
<div
- v-if="count===0"
+ v-if="count === 0"
class="new-status-notification text-center faint"
>
{{ $t('timeline.no_statuses') }}
</div>
<div
- v-else-if="bottomedOut"
+ v-else-if="timeline.fetcher.bottomedOut"
class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
<button
- v-else-if="!timeline.loading"
+ v-else-if="!timeline.fetcher.loading"
class="button-unstyled -link"
@click.prevent="fetchOlderStatuses()"
>
<div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
<div
v-else
class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
</teleport>
<!-- spacer to avoid having empty shrug -->
<span v-if="embedded && footerSlipgate" />
</div>
</div>
</template>
<script src="./timeline.js"></script>
<style src="./timeline.scss" lang="scss"> </style>
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
deleted file mode 100644
index 74d2ea22f7..0000000000
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import { promiseInterval } from '../promise_interval/promise_interval.js'
-
-import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
-import { useInterfaceStore } from 'src/stores/interface.js'
-import { useMergedConfigStore } from 'src/stores/merged_config.js'
-import { useStatusesStore } from 'src/stores/statuses.js'
-import { ARGUMENT_MAP, useTimelinesStore } from 'src/stores/timelines.js'
-import { useUsersStore } from 'src/stores/users.js'
-
-import { fetchTimeline } from 'src/api/timelines.js'
-
-const REPLY_VISIBILITY_TIMELINES = new Set([
- 'friends',
- 'public',
- 'publicAndExternal',
- 'bubble',
-])
-
-const fetchAndUpdate = (
- { timeline, argument, credentials },
- { older = false, showImmediately = false },
-) => {
- timeline.loading = true
- const { hideMutedPosts, replyVisibility } =
- useMergedConfigStore().mergedConfig
- const loggedIn = useUsersStore().loggedIn
-
- const args = { timeline: timeline.name, credentials }
- const mainArg = ARGUMENT_MAP[timeline.name]
- if (mainArg) args[mainArg] = argument
-
- if (older) {
- // When minId = 0 we need to fetch without maxId param
- args.maxId = timeline.minId || null
- } else {
- args.sinceId = timeline.maxId || null
- }
-
- args.withMuted = !hideMutedPosts
- if (loggedIn && REPLY_VISIBILITY_TIMELINES.has(timeline)) {
- args.replyVisibility = replyVisibility
- }
-
- const numStatusesBeforeFetch = timeline.statusIds.size
-
- return fetchTimeline(args)
- .then((response) => {
- const { data: statuses, pagination, timestamp } = response
- if (
- !older &&
- statuses.length >= 20 &&
- !timeline.loading &&
- numStatusesBeforeFetch > 0
- ) {
- useTimelinesStore().queueFlush(timeline.name, timeline.maxId)
- }
-
- const processed = useStatusesStore()
- .addNewStatuses({ statuses, timestamp })
- .filter(Boolean)
- .map(({ id }) => id)
-
- useTimelinesStore().addStatusesToTimeline(timeline.name, argument, {
- statuses: processed,
- showImmediately,
- older,
- pagination,
- })
- return { statuses, pagination }
- })
- .catch((error) => {
- if (error.statusCode === 403 && timeline === 'favorites') {
- useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable = false
- return
- }
- console.error('Timeline Error', error)
- useInterfaceStore().pushGlobalNotice({
- level: 'error',
- messageKey: 'timeline.error',
- messageArgs: [error.message],
- timeout: 5000,
- })
- })
- .finally(() => {
- timeline.loading = false
- })
-}
-
-const timelineFetcher = (timeline, argument, credentials) => {
- const state = {
- interval: null,
- }
-
- const boundFetchAndUpdate = ({
- showImmediately,
- older,
- } = {}) =>
- fetchAndUpdate(
- {
- timeline,
- argument,
- credentials,
- },
- {
- older,
- showImmediately,
- },
- )
-
- const startFetching = () => {
- if (state.interval) throw new Error('Interval already exists!')
-
- boundFetchAndUpdate({
- showImmediately: timeline.visibleStatusIds.size === 0,
- })
-
- state.interval = promiseInterval(boundFetchAndUpdate, 10000)
- }
-
- const stopFetching = () => {
- state.interval.stop()
- state.interval = null
- }
-
- return {
- startFetching,
- stopFetching,
- fetchAndUpdate: boundFetchAndUpdate,
- }
-}
-
-export default timelineFetcher
diff --git a/src/stores/fetchers/timeline_fetcher.js b/src/stores/fetchers/timeline_fetcher.js
new file mode 100644
index 0000000000..21653b9a71
--- /dev/null
+++ b/src/stores/fetchers/timeline_fetcher.js
@@ -0,0 +1,121 @@
+import { ref } from 'vue'
+import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
+
+import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
+import { useInterfaceStore } from 'src/stores/interface.js'
+import { useMergedConfigStore } from 'src/stores/merged_config.js'
+import { useStatusesStore } from 'src/stores/statuses.js'
+import { ARGUMENT_MAP, useTimelinesStore } from 'src/stores/timelines.js'
+import { useUsersStore } from 'src/stores/users.js'
+
+import { fetchTimeline } from 'src/api/timelines.js'
+
+const REPLY_VISIBILITY_TIMELINES = new Set([
+ 'friends',
+ 'public',
+ 'publicAndExternal',
+ 'bubble',
+])
+
+const timelineFetcher = (timeline, argument, credentials) => {
+ const loading = ref(false)
+ const bottomedOut = ref(false)
+ const interval = ref(null)
+
+ const fetchAndUpdate = ({ older = false, showImmediately = false } = {}) => {
+ loading.value = true
+
+ const { hideMutedPosts, replyVisibility } =
+ useMergedConfigStore().mergedConfig
+ const loggedIn = useUsersStore().loggedIn
+
+ const args = { timeline: timeline.name, credentials }
+ const mainArg = ARGUMENT_MAP[timeline.name]
+ if (mainArg) args[mainArg] = argument
+
+ if (older) {
+ // When minId = 0 we need to fetch without maxId param
+ args.maxId = timeline.minId || null
+ } else {
+ args.sinceId = timeline.maxId || null
+ }
+
+ args.withMuted = !hideMutedPosts
+ if (loggedIn && REPLY_VISIBILITY_TIMELINES.has(timeline)) {
+ args.replyVisibility = replyVisibility
+ }
+
+ const numStatusesBeforeFetch = timeline.statusIds.size
+
+ return fetchTimeline(args)
+ .then(({ data: statuses, pagination, timestamp }) => {
+ if (
+ !older &&
+ !loading.value &&
+ statuses.length >= 20 &&
+ numStatusesBeforeFetch > 0
+ ) {
+ useTimelinesStore().queueFlush(timeline.name, timeline.maxId)
+ }
+
+ if (older && statuses.length === 0) {
+ bottomedOut.value = true
+ }
+
+ const processed = useStatusesStore()
+ .addNewStatuses({ statuses, timestamp })
+ .map(({ id }) => id)
+
+ useTimelinesStore().addStatusesToTimeline(timeline.name, argument, {
+ statuses: processed,
+ showImmediately,
+ older,
+ pagination,
+ })
+
+ return { statuses, pagination }
+ })
+ .catch((error) => {
+ if (error.statusCode === 403 && timeline === 'favorites') {
+ useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable = false
+ return
+ }
+ console.error('Timeline Error', error)
+ useInterfaceStore().pushGlobalNotice({
+ level: 'error',
+ messageKey: 'timeline.error',
+ messageArgs: [error.message],
+ timeout: 5000,
+ })
+ })
+ .finally(() => {
+ loading.value = false
+ })
+ }
+
+ const startFetching = () => {
+ if (interval.value) throw new Error('Interval already exists!')
+
+ fetchAndUpdate({
+ showImmediately: timeline.visibleStatusIds.size === 0,
+ })
+
+ interval.value = promiseInterval(fetchAndUpdate, 10000)
+ }
+
+ const stopFetching = () => {
+ interval.value.stop()
+ interval.value = null
+ }
+
+ return {
+ startFetching,
+ stopFetching,
+ fetchOlder: () => fetchAndUpdate({ showImmediately: true, older: true }),
+ fetchNewer: () => fetchAndUpdate({ showImmediately: true, older: false }),
+ loading,
+ bottomedOut,
+ }
+}
+
+export default timelineFetcher
diff --git a/src/stores/timelines.js b/src/stores/timelines.js
index 9726fc9a0f..9a329cdff9 100644
--- a/src/stores/timelines.js
+++ b/src/stores/timelines.js
@@ -1,317 +1,316 @@
import { first, last, max, min } from 'lodash'
import { defineStore } from 'pinia'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { TIMELINE_STREAM_MAP, useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js'
-import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js'
+import timelineFetcher from 'src/stores/fetchers/timeline_fetcher.js'
const emptyTl = (name, argument = null) => {
const result = {
name,
order: [],
statusIds: new Set(),
visibleStatusIds: new Set(),
newStatusCount: 0,
maxId: '',
minId: '',
- loading: false,
streaming: false,
flushMarker: 0,
fetcher: null,
socket: null,
}
const property = ARGUMENT_MAP[name]
if (property) {
result[property] = argument
}
if (name === 'dms' || name === 'friends') {
result.persistent = true
}
return result
}
export const ARGUMENT_MAP = {
tag: 'tag',
list: 'listId',
bookmarks: 'bookmarkFolderId',
quotes: 'statusId',
search: 'query',
user: 'userId',
userPinned: 'userId',
media: 'userId',
}
const TIMELINES = new Set([
'mentions',
'public',
'user',
'userPinned',
'media',
'favorites',
'publicAndExternal',
'friends',
'tag',
'dms',
'bookmarks',
'list',
'bubble',
'quotes',
'search',
])
export const defaultState = () => {
return Object.fromEntries([...TIMELINES].map((name) => [name, emptyTl(name)]))
}
//const CUSTOM_SORT = new Set(['bookmarks', 'favorites'])
export const useTimelinesStore = defineStore('timelines', {
state: defaultState,
actions: {
// (De)Initialization stuff
activate(timelineName, argument, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (
timelineName === 'favourites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
) {
console.warn("Instance doesn't support public favorites timeline")
return
}
const property = ARGUMENT_MAP[timelineName]
if (property) {
timeline[property] = argument
}
timeline.fetcher = timelineFetcher(
timeline,
argument,
useOAuthStore().token,
)
this.startFetchingTimeline(timelineName, argument, 'Timeline activated')
const streamName = TIMELINE_STREAM_MAP[timelineName]
if (streamName) {
const et = new EventTarget()
const openHandler = () => this.onStreamConnect(timelineName, argument)
const closeHandler = () =>
this.onStreamDisconnect(timelineName, argument)
const messageHandler =
() =>
({ detail: message }) =>
this.onStreamMessage(timelineName, argument, message)
et.addEventListener('open', openHandler)
et.addEventListener('close', closeHandler)
et.addEventListener('update', messageHandler)
timeline.socket = {
stream: {
name: streamName,
argument,
},
et,
handlers: {
openHandler,
closeHandler,
messageHandler,
},
}
useStreamingStore().addSubscriber(timeline.socket)
}
},
deactivate(timelineName, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (!timeline.streaming) {
this.stopFetchingTimeline(timelineName, 'Timeline deactivation')
}
if (timeline.socket) {
useStreamingStore().removeSubscriber(timeline.socket)
const { openHandler, closeHandler, messageHandler } =
timeline.socket.handlers
timeline.socket.et.removeEventListener('open', openHandler)
timeline.socket.et.removeEventListener('close', closeHandler)
timeline.socket.et.removeEventListener('message', messageHandler)
}
this[timelineName] = emptyTl(timelineName)
},
activatePersistents() {
TIMELINES.forEach((name) => {
if (this[name].persistent) {
this.activate(name, undefined, true)
}
})
},
deactivateAll() {
TIMELINES.forEach((name) => {
try {
this.deactivate(name, true)
} catch (e) {
console.error(`Failed to deactivate timeline ${name}`)
}
})
},
// Update stuff
addStatusesToTimeline(
timelineName,
argument,
{
statuses,
showImmediately = false,
noIdUpdate = false,
pagination = {},
older = false
},
) {
if (statuses.length === 0) return
const timeline = this[timelineName]
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
// Same can happen with tags etc.
const property = ARGUMENT_MAP[timelineName]
if (property && timeline[property] !== argument) {
return
}
if (!noIdUpdate) {
this.updateTimelineExtremes(
timeline,
pagination,
)
}
const filtered = statuses.filter((id) => !timeline.statusIds.has(id))
if (older) {
timeline.order.push(...filtered)
} else {
timeline.order.unshift(...filtered)
}
statuses.forEach((statusId) => {
const isNew = !timeline.statusIds.has(statusId)
timeline.statusIds.add(statusId)
if (isNew) {
if (showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
timeline.visibleStatusIds.add(statusId)
} else {
// Just change newStatuscount
timeline.newStatusCount += 1
}
}
})
},
onStreamMessage(timeline, argument, event) {
this.addStatusesToTimeline(timeline, argument, {
statuses: [event.data.status.id],
})
},
// Poll & Push
onStreamConnect(timeline) {
console.debug('[Timelines] Stream connected', timeline)
this[timeline].streaming = true
this.stopFetchingTimeline(timeline, 'Socket connected')
},
onStreamDisconnect(timeline, argument) {
console.debug('[Timelines] Stream disconnected', timeline, argument)
this[timeline].streaming = false
this.startFetchingTimeline(timeline, argument, 'Socket disconnected')
},
startFetchingTimeline(timelineName, argument, reason) {
console.debug(
'[Timelines] Starting fetching timeline',
timelineName,
argument,
'Reason:',
reason,
)
const timeline = this[timelineName]
timeline.fetcher.startFetching()
},
stopFetchingTimeline(timelineName, reason) {
console.debug(
'[Timelines] Stopped fetching timeline',
timelineName,
'Reason:',
reason,
)
const timeline = this[timelineName]
timeline.fetcher.stopFetching()
},
// Queues & Timeline manip
updateTimelineExtremes(timeline, pagination = {}) {
// Can't use Math.min/max because it doesn't work with string (duh)
const minNew = pagination.maxId ?? last(timeline.order) ?? ''
const maxNew = pagination.minId ?? first(timeline.order) ?? ''
const newer = maxNew > timeline.maxId
const older = minNew < timeline.minId
if (newer || timeline.maxId === '') {
timeline.maxId = maxNew
}
if (older || timeline.minId === '') {
timeline.minId = minNew
}
this.syncOrder(timeline)
},
showNewStatuses(timelineName) {
const timeline = this[timelineName]
timeline.newStatusCount = 0
timeline.visibleStatusIds = new Set([...timeline.statusIds])
},
syncOrder(timeline) {
timeline.order = timeline.order.filter((id) => timeline.statusIds.has(id))
},
queueFlush(timeline, id) {
this[timeline].flushMarker = id
},
queueFlushAll() {
Object.keys(this).forEach((timeline) => {
this[timeline].flushMarker = this[timeline].maxId
})
},
// Misc
wipeStatuses(ids) {
TIMELINES.forEach((timelineName) => {
const timeline = this[timelineName]
ids.forEach((id) => {
timeline.statusIds.delete(id)
timeline.visibleStatusIds.delete(id)
})
this.syncOrder(timeline)
})
},
},
})
diff --git a/src/stores/users.js b/src/stores/users.js
index cb0e0afd42..98589f0206 100644
--- a/src/stores/users.js
+++ b/src/stores/users.js
@@ -1,753 +1,753 @@
import Cookies from 'js-cookie'
import { last } from 'lodash'
import { defineStore } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useListsStore } from 'src/stores/lists.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { revokeToken } from 'src/api/oauth.js'
import {
fetchFollowers,
fetchFriends,
fetchUser,
fetchUserByName,
searchUsers,
verifyCredentials,
} from 'src/api/public.js'
import {
blockUser,
editUserNote,
fetchBlocks,
fetchDomainMutes,
fetchMutes,
fetchUserInLists,
fetchUserRelationship,
followUser,
muteDomain,
muteUser,
removeUserFromFollowers,
unblockUser,
unfollowUser,
unmuteDomain,
unmuteUser,
} from 'src/api/user.js'
import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
export const useUsersStore = defineStore('users', {
state: () => ({
loggingIn: false,
lastLoginName: null,
currentUser: null,
users: new Map(),
usersByName: new Map(),
usersByURL: new Map(),
relationships: new Map(),
relationshipsLists: {
friends: new WeakMap(),
followers: new WeakMap(),
},
timestamps: new WeakMap(),
fetchesIds: new Map(),
fetchesNames: new Map(),
followPollers: new Map(),
followPollersAttempts: new Map(),
}),
getters: {
loggedIn: (state) => !!state.currentUser,
findUser: (state) => (query) => {
return state.users.get(query)
},
findUserByName: (state) => (query) => {
return state.usersByName.get(query.toLowerCase())
},
findUserByUrl: (state) => (query) => {
return state.usersByURL.get(query.toLowerCase())
},
relationship: (state) => (id) => {
const rel = id && state.relationships.get(id)
return rel || { id, loading: true }
},
},
actions: {
// Main updates
addNewUsers(response) {
const { data, timestamp } = response
const users = Array.isArray(data) ? data : [data]
return users.map((user) => {
const existing = this.users.get(user.id) ?? {}
const oldTimestamp = this.timestamps.get(existing)
//
// Relationship might have different timestamp and
// might need updating separate from user
const oldRelationship = this.relationships.get(user.id)
const oldRelationshipTimestamp = this.timestamps.get(oldRelationship)
let relationship
// Only need an update if new user data has relationship
if (user.relationship) {
// Only update if there is no old data or if it's outdated
if (
oldRelationship === undefined ||
timestamp > oldRelationshipTimestamp
) {
relationship = this.updateUserRelationships({
timestamp,
data: { id: user.id, ...user.relationship },
})[0]
}
} else {
relationship = oldRelationship
}
// implicit: if oldTimestamp is undefined this will still be false
if (oldTimestamp > timestamp) return existing // not overwriting old data with new
const { relationship: unused1, ...newUser } = user
let reactive = this.users.get(user.id)
// Initializing reactivity & avoiding excessive Map mutation
if (!reactive) {
this.users.set(user.id, existing)
reactive = this.users.get(user.id)
this.usersByName.set(user.screen_name.toLowerCase(), reactive)
this.usersByURL.set(user.url.toLowerCase(), reactive)
}
// Relying on object reactivity to avoid mutating the Map
reactive.relationship = relationship ?? reactive.relationship
Object.entries(newUser).forEach(([k, v]) => {
reactive[k] = v
})
// Updating the timestamp
this.timestamps.set(reactive, timestamp)
if (user.id === this.currentUser?.id) {
this.currentUser = reactive
}
// Initialize some stuff
const { friends, followers } = this.relationshipsLists
if (!friends.has(reactive)) friends.set(reactive, new Set())
if (!followers.has(reactive)) followers.set(reactive, new Set())
return reactive
})
},
updateUserRelationships({ timestamp, optimism, data }) {
const relationships = Array.isArray(data) ? data : [data]
return relationships.map((relationship) => {
const { id } = relationship
const existing = this.relationships.get(id) ?? {}
const oldTimestamp = this.timestamps.get(existing)
// implicit: if oldTimestamp is undefined this will still be false
if (!optimism && oldTimestamp > timestamp) return existing
// Initializing reactivity
if (!this.relationships.has(id)) this.relationships.set(id, existing)
const reactive = this.relationships.get(id)
// Relying on reactivity
Object.entries(relationship).forEach(([k, v]) => {
reactive[k] = v
})
if (timestamp) {
this.timestamps.set(reactive, timestamp)
}
// Updating user property if there is such a user
if (this.users.has(id)) {
this.users.get(id).relationship = reactive
}
// Update block/mute lists
if (this.currentUser && id !== this.currentUser.id) {
;[
['muting', this.currentUser.muteIds],
['blocking', this.currentUser.blockIds],
[
'following',
this.relationshipsLists.friends.get(this.currentUser),
],
[
'followed_by',
this.relationshipsLists.followers.get(this.currentUser),
],
].forEach(([relationshipName, list]) => {
if (relationship[relationshipName]) {
list?.add(id)
} else {
list?.delete(id)
}
})
}
return reactive
})
},
// Misc updates
updateUserAdminData(id, data) {
const user = this.users.get(id)
user.adminData = data
user.deactivated = !data.is_active
user.tags = new Set(data.tags)
},
updateRight(id, right, value) {
const user = this.users.get(id)
const newRights = user.rights ?? {}
newRights[right] = value
user.rights = newRights
},
// Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile.
clearFollowLists(userId) {
const user = this.users.get(userId)
if (user) {
this.relationshipsLists.friends.set(user, new Set())
this.relationshipsLists.followers.set(user, new Set())
}
},
// Fetches
async fetchUserIfMissing({ id, name }) {
let findFunc
let fetchFunc
let map
let identifier
if (id) {
findFunc = this.findUser
fetchFunc = this.fetchUser
map = this.fetchesIds
identifier = id
} else if (name) {
findFunc = this.findUserByName
fetchFunc = this.fetchUserByName
map = this.fetchesNames
identifier = name
} else {
throw new TypeError('No identifier provided')
}
// Search in cache
const user = findFunc(identifier)
if (user) return user
// not found => fetch
let promise
// Did we already search for this user?
if (map.has(identifier)) {
// if so, reuse the promise
promise = map.get(identifier)
} else {
// if not, make a new one
promise = fetchFunc(identifier)
}
map.set(identifier, promise)
const result = await promise
if (result) {
const { id, screen_name } = result
// Save promise for future use
this.fetchesIds.set(id, promise)
this.fetchesNames.set(screen_name, promise)
return this.users.get(id)
} else {
return null
}
},
async fetchUser(id) {
try {
const result = await fetchUser({
id,
credentials: useOAuthStore().token,
})
this.addNewUsers(result)
return this.users.get(result.data.id)
} catch (error) {
if (error.name === 'StatusCodeError' && error.statusCode === 404) {
console.warn(`User ${id} not found`)
return null
} else {
throw error
}
}
},
async fetchUserByName(name) {
try {
const result = await fetchUserByName({
name,
credentials: useOAuthStore().token,
})
this.addNewUsers(result)
return this.users.get(result.data.id)
} catch (error) {
if (error.name === 'StatusCodeError' && error.statusCode === 404) {
console.warn(`User ${name} not found`)
return null
} else {
throw error
}
}
},
fetchUserRelationship(id) {
return fetchUserRelationship({
id,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
fetchFriends(id) {
const user = this.users.get(id)
const maxId = last([...this.relationshipsLists.friends.get(user)])
return fetchFriends({
id,
maxId,
credentials: useOAuthStore().token,
}).then((result) => {
const users = this.addNewUsers(result)
const list = this.relationshipsLists.friends.get(user)
users.forEach(({ id }) => list.add(id))
return result.data
})
},
fetchFollowers(id) {
const user = this.users.get(id)
const maxId = last([...this.relationshipsLists.followers.get(user)])
return fetchFollowers({
id,
maxId,
credentials: useOAuthStore().token,
}).then((result) => {
const users = this.addNewUsers(result)
const list = this.relationshipsLists.followers.get(user)
users.forEach(({ id }) => list.add(id))
return result.data
})
},
fetchUserInLists(id) {
if (this.currentUser) {
return fetchUserInLists({
id,
credentials: useOAuthStore().token,
}).then(({ data: inLists }) => {
this.users.get(id).inLists = inLists
})
}
},
fetchMutes(args) {
const { reset } = args || {}
const maxId = this.currentUser.muteIdsMaxId
return fetchMutes({
maxId,
credentials: useOAuthStore().token,
}).then((result) => {
const { data: mutes } = result
if (reset) {
this.currentUser.muteIds = new Set(mutes.map(({ id }) => id))
} else {
mutes.forEach(({ id }) => this.currentUser.muteIds.add(id))
}
if (mutes.length) {
this.currentUser.muteIdsMaxId = last(mutes).id
}
this.addNewUsers(result)
return mutes
})
},
fetchBlocks(args) {
const { reset } = args || {}
const maxId = this.currentUser.blockIdsMaxId
return fetchBlocks({
maxId,
credentials: useOAuthStore().token,
}).then((result) => {
const { data: blocks } = result
if (reset) {
this.currentUser.blockIds = new Set(blocks.map(({ id }) => id))
} else {
blocks.forEach(({ id }) => this.currentUser.blockIds.add(id))
}
if (blocks.length) {
this.currentUser.blockIdsMaxId = last(blocks).id
}
this.addNewUsers(result)
return blocks
})
},
fetchDomainMutes() {
return fetchDomainMutes({
credentials: useOAuthStore().token,
}).then(({ data: domainMutes }) => {
this.currentUser.domainMutes = new Set(domainMutes)
return domainMutes
})
},
// Actions
/// Follow
async followUser(id) {
// Don't spam follow requests if we are already polling
if (this.followPollers.has(id)) return
const followFunc = () =>
followUser({
id,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
const checker = async (func = () => this.fetchUserRelationship(id)) => {
await func() // Either fetch relationships or follow request
const relationship = this.relationships.get(id)
return (
relationship.following ||
(relationship.locked && relationship.requested)
)
}
const immediate = await checker(followFunc)
if (immediate) return // If follow goes through immediately don'd to looping
const loop = async () => {
const attempts = this.followPollersAttempts.get(id)
const result = await checker()
if (result || attempts === 1) {
this.followPollersAttempts.delete(id)
this.followPollers.get(id).stop()
this.followPollers.delete(id)
} else {
this.followPollersAttempts.set(id, attempts - 1)
}
}
this.followPollersAttempts.set(id, 3)
this.followPollers.set(id, promiseInterval(loop, 500))
},
async unfollowUser(id) {
const result = await unfollowUser({
id,
credentials: useOAuthStore().token,
})
return this.updateUserRelationships(result)
},
/// Subscribe
subscribeUser(id) {
return followUser({
id,
notify: true,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
unsubscribeUser(id) {
return followUser({
id,
notify: false,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
/// User Note
editUserNote(id, comment) {
return editUserNote({ id, comment }).then((result) =>
this.updateUserRelationships(result),
)
},
/// Hide reblogs
hideReblogs(id) {
return followUser({
id,
reblogs: false,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
showReblogs(id) {
return followUser({
id,
reblogs: true,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
},
/// Remove follower
removeUserFromFollowers(id) {
return removeUserFromFollowers({ id }).then((result) =>
this.updateUserRelationships(result),
)
},
/// Mute
muteUser(id, expiresIn = 0) {
const predictedRelationship = this.relationships[id] || { id }
predictedRelationship.muting = true
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
return muteUser({
id,
expiresIn,
credentials: useOAuthStore().token,
}).then((result) => {
this.updateUserRelationships(result)
})
},
muteUsers(data = []) {
return Promise.all(data.map((d) => this.muteUser(d)))
},
unmuteUser(id) {
const predictedRelationship = this.relationships[id] || { id }
predictedRelationship.muting = false
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
return unmuteUser({ id }).then((result) =>
this.updateUserRelationships(result),
)
},
unmuteUsers(ids = []) {
return Promise.all(ids.map((d) => this.unmuteUser(d)))
},
/// Block
blockUser(id, expiresIn = 0) {
const predictedRelationship = this.relationships[id] || { id }
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
return blockUser({ id, expiresIn }).then((result) => {
this.updateUserRelationships(result)
- useStatusesStore().wipeUserStatuses(id)
- useTimelinesStore().wipeUserStatuses(id)
+ const ids = useStatusesStore().wipeUserStatuses(id)
+ useTimelinesStore().wipeStatuses(ids)
})
},
blockUsers(data = []) {
return Promise.all(data.map((d) => this.blockUser(d)))
},
unblockUser(id) {
return unblockUser({ id }).then((result) => {
this.updateUserRelationships(result)
})
},
unblockUsers(data = []) {
return Promise.all(data.map((d) => this.unblockUser(d)))
},
/// Domain Mute
muteDomain(domain) {
return muteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => this.currentUser.domainMutes.add(domain))
},
unmuteDomain(domain) {
return unmuteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => this.currentUser.domainMutes.delete(domain))
},
muteDomains(domains = []) {
return Promise.all(domains.map((domain) => this.muteDomain(domain)))
},
unmuteDomains(domain = []) {
return Promise.all(domain.map((domain) => this.unmuteDomain(domain)))
},
// Login/Logout
async loginUser(accessToken) {
const store = window.vuex
const dispatch =
store?.dispatch ??
(() => {
/* no-op */
}) // for tests
this.loggingIn = true
try {
const { data: user, ...rest } = await verifyCredentials({
credentials: useOAuthStore().token,
})
user.blockIds = new Set()
user.muteIds = new Set()
user.domainMutes = new Set()
this.lastLoginName = user.screen_name
this.currentUser = user
this.users = new Map()
this.usersByName = new Map()
this.usersByURL = new Map()
this.relationships = new Map()
useInterfaceStore().onLogin()
useSyncConfigStore()
.initSyncConfig(user)
.then(() => {
useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
})
useUserHighlightStore().initUserHighlight(user)
this.addNewUsers({ data: user, ...rest })
useEmojiStore().fetchEmoji()
// Do server-side storage migrations
// Debug snippet to clean up storage and reset migrations
/*
// Reset wordfilter
Object.keys(
useSyncConfigStore().prefsStorage.simple.muteFilters
).forEach(key => {
useSyncConfigStore().unsetSimplePrefAndSave({ path: 'muteFilters.' + key, value: null })
})
// Reset flag to 0 to re-run migrations
useSyncConfigStore().setFlag({ flag: 'configMigration', value: 0 })
/**/
if (user.token) {
// Shoutbox
dispatch('setWsToken', user.token)
dispatch('initializeSocket')
}
// DMs and Home
useNotificationsStore().activate()
useTimelinesStore().activatePersistents()
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
// Start fetching chats
useChatsStore().startFetching()
}
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
if (useMergedConfigStore().mergedConfig.useStreamingApi) {
useStreamingStore().initSocket(true)
}
// Start fetching things that don't need to block the UI
useAnnouncementsStore().startFetching()
this.fetchMutes()
dispatch('loadDrafts')
} catch (error) {
console.error(error)
// Authentication failed
this.loggingIn = false
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(error.statusCode)) {
useOAuthStore().clearToken()
}
if (error.tatusCode === 401) {
throw new Error('Wrong username or password', error)
} else {
throw new Error('An error occurred, please try again', error)
}
} finally {
this.loggingIn = false
}
},
logout() {
const store = window.vuex
const oauth = useOAuthStore()
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return oauth
.ensureApp()
.then((app) => {
const params = {
app,
instance: useInstanceStore().server,
token: oauth.userToken,
}
return revokeToken(params)
})
.then(() => {
this.currentUser = null
this.lastLoginName = null
this.users = new Map()
this.usersByName = new Map()
this.usersByURL = new Map()
this.relationships = new Map()
useNotificationsStore().deactivate()
useAnnouncementsStore().stopFetching()
useListsStore().stopFetching()
useBookmarkFoldersStore().stopFetching()
store?.dispatch('stopFetchingFollowRequests')
useTimelinesStore().deactivateAll()
useStatusesStore().resetStatuses()
if (useMergedConfigStore().mergedConfig.useStreamingApi) {
useStreamingStore().stopSocket()
}
useChatsStore().resetChats()
oauth.clearToken()
Cookies.remove('__Host-pleroma_key', { path: '/' })
useInterfaceStore().onLogout()
})
},
},
persist: {
paths: ['lastLoginName'],
},
})
diff --git a/test/unit/specs/stores/users.spec.js b/test/unit/specs/stores/users.spec.js
index b234a0ffe1..e9e557960e 100644
--- a/test/unit/specs/stores/users.spec.js
+++ b/test/unit/specs/stores/users.spec.js
@@ -1,1257 +1,1257 @@
import { createTestingPinia } from '@pinia/testing'
import { snakeCase } from 'lodash'
import { setActivePinia } from 'pinia'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useListsStore } from 'src/stores/lists.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import * as PUBLIC_API from 'src/api/public.js'
import * as USER_API from 'src/api/user.js'
const DEFAULT_OPTIONS = (method = 'POST') => ({
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
})
const actionKeys = (action) => {
const result = {}
if (action === 'removeUserFromFollowers') {
result.storeAction = action
} else {
result.storeAction = action + 'User'
}
result.apiUrl = `MASTODON_${snakeCase(result.storeAction).toUpperCase()}_URL`
return result
}
const userId = '1'
const userScreenName = 'user'
const userName = 'Guy'
const userUrl = 'http://localhost/user'
const mockMastoAPIUser = ({
screen_name = userScreenName,
name = userName,
url = userUrl,
id = userId,
} = {}) => ({
id,
acct: screen_name,
display_name: name,
fields: [],
avatar: '',
url,
})
const mockUser = ({
screen_name = userScreenName,
id = userId,
name = userName,
url = userUrl,
} = {}) => ({
_original: mockMastoAPIUser({
screen_name,
id,
name,
url,
}),
id,
name,
screen_name,
url,
relationship: undefined,
})
describe('Users store', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('addNewUsers', () => {
describe('users', () => {
it('adds new users to the set, merging in new information for old users', () => {
const store = useUsersStore()
const modUser = mockUser({ name: 'Dude' })
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
expect(store.users).to.have.length(1)
expect(store.users).to.have.all.keys(userId)
store.addNewUsers({ data: [modUser], timestamp: 2 })
expect(store.users).to.have.length(1)
expect(store.users).to.have.all.keys(userId)
expect(store.users.get(userId).name).to.eql('Dude')
})
it('ignores new users if timestamp is older', () => {
const store = useUsersStore()
const modUser = mockUser({ name: 'Old guy' })
store.addNewUsers({ data: [mockUser()], timestamp: 2000 })
expect(store.users).to.have.length(1)
expect(store.users).to.have.all.keys(userId)
expect(store.users.get(userId).name).to.eql('Guy')
store.addNewUsers({ data: [modUser], timestamp: 1991 })
expect(store.users).to.have.length(1)
expect(store.users).to.have.all.keys(userId)
expect(store.users.get(userId).name).to.eql('Guy')
})
it('merging array field in new information for old users', () => {
const store = useUsersStore()
const userFields = {
...mockUser(),
fields: [{ name: 'Label 1', value: 'Content 1' }],
}
const firstModUser = {
...mockUser(),
fields: [
{ name: 'Label 2', value: 'Content 2' },
{ name: 'Label 3', value: 'Content 3' },
],
}
const secondModUser = {
...mockUser(),
fields: [{ name: 'Label 4', value: 'Content 4' }],
}
store.addNewUsers({ data: [userFields], timestamp: 1 })
const reactive = store.users.get(userId)
expect(reactive.fields).to.have.length(1)
expect(reactive.fields[0].name).to.eql('Label 1')
store.addNewUsers({ data: [firstModUser], timestamp: 2 })
expect(reactive.fields).to.have.length(2)
expect(reactive.fields[0].name).to.eql('Label 2')
expect(reactive.fields[1].name).to.eql('Label 3')
store.addNewUsers({ data: [secondModUser], timestamp: 3 })
expect(reactive.fields).to.have.length(1)
expect(reactive.fields[0].name).to.eql('Label 4')
})
})
describe('relationships', () => {
it('updates relationship information if present', () => {
const store = useUsersStore()
const modUser = {
...mockUser(),
relationship: {
id: userId,
following: true,
},
}
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
store.addNewUsers({ data: [modUser], timestamp: 2 })
expect(store.relationships).to.have.length(1)
expect(store.relationships).to.have.all.keys(userId)
expect(store.relationship(userId).following).to.eql(true)
expect(store.findUser(userId).relationship.following).to.eql(true)
})
it('updates relationship information if present even if user timestamp is older', () => {
const store = useUsersStore()
const modUser = {
...mockUser({ name: 'Old Dude' }),
relationship: {
id: userId,
following: true,
},
}
store.addNewUsers({ data: [mockUser()], timestamp: 2000 })
store.addNewUsers({ data: [modUser], timestamp: 1991 })
expect(store.relationships).to.have.length(1)
expect(store.relationships).to.have.all.keys(userId)
expect(store.relationship(userId).following).to.eql(true)
expect(store.findUser(userId).relationship.following).to.eql(true)
})
it("doesn't erase relationship information if new data has it missing", () => {
const store = useUsersStore()
const modUser = mockUser({ name: 'Dude' })
store.addNewUsers({
data: [
{ ...mockUser(), relationship: { id: userId, following: true } },
],
timestamp: 1,
})
store.addNewUsers({ data: [modUser], timestamp: 2 })
expect(store.relationships).to.have.length(1)
expect(store.relationships).to.have.all.keys(userId)
expect(store.relationship(userId).following).to.eql(true)
expect(store.findUser(userId).relationship.following).to.eql(true)
})
})
})
describe('updateUserRelationships', () => {
it('updates existing user relationship', () => {
const store = useUsersStore()
const relationship = { id: userId, following: true }
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
store.updateUserRelationships({ data: relationship, timestamp: 2 })
expect(store.relationship(userId)).to.eql({ id: userId, following: true })
expect(store.findUser(userId).relationship.following).to.eql(true)
})
it('stores relationship for missing user', () => {
const store = useUsersStore()
const relationship = { id: userId, following: true }
store.updateUserRelationships({ data: relationship, timestamp: 1 })
expect(store.relationship(userId)).to.eql({ id: userId, following: true })
})
it('assigns relationship for missing user when it becomes available', () => {
const store = useUsersStore()
const relationship = { id: userId, following: true }
store.updateUserRelationships({ data: relationship, timestamp: 1 })
store.addNewUsers({ data: [mockUser()], timestamp: 2 })
expect(store.relationship(userId)).to.eql({ id: userId, following: true })
expect(store.findUser(userId).relationship).to.eql({
id: userId,
following: true,
})
})
it('ignores relationship update if timestamp is older than existing', () => {
const store = useUsersStore()
const oldRelationship = { id: userId, following: true }
const newRelationship = { id: userId, following: false }
store.addNewUsers({
data: [{ ...mockUser(), relationship: newRelationship }],
timestamp: 2000,
})
store.updateUserRelationships({ data: oldRelationship, timestamp: 1991 })
expect(store.relationship(userId)).to.eql({
id: userId,
following: false,
})
expect(store.findUser(userId).relationship.following).to.eql(false)
})
})
describe('fetchers', () => {
describe('fetchUserIfMissing', () => {
it('should fetch requested user, add it to store and return it', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIUser()), {
headers: { 'Content-Type': 'application/json' },
}),
),
)
const expected = mockUser()
const store = useUsersStore()
const resultUser = await store.fetchUserIfMissing({ id: '1' })
expect(resultUser).to.deep.include(expected)
expect(store.findUser(userId)).to.deep.include(expected)
})
it('Should re-use existing promise for other fetches', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: '1' }), {
headers: { 'Content-Type': 'application/json' },
}),
)
// fetch by name yields user id which we request next
.mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIUser()), {
headers: { 'Content-Type': 'application/json' },
}),
)
.mockThrowOnce(new Error("Shouldn't be called more than once")),
)
const expected = mockUser()
const store = useUsersStore()
const resultUser1 = await store.fetchUserIfMissing({ name: 'user' })
const resultUser2 = await store.fetchUserIfMissing({ id: '1' })
expect(resultUser1).to.deep.include(expected)
expect(resultUser2).to.deep.include(expected)
expect(store.findUser(userId)).to.deep.include(expected)
})
it('Should use cached data if present', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockThrowOnce(new Error("Shouldn't be called at all")),
)
const store = useUsersStore()
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
const resultUser1 = await store.fetchUserIfMissing({ id: '1' })
const resultUser2 = await store.fetchUserIfMissing({ name: 'user' })
const expected = mockUser()
expect(resultUser1).to.deep.include(expected)
expect(resultUser2).to.deep.include(expected)
expect(store.findUser(userId)).to.deep.include(expected)
})
it('Should handle 404 gracefully', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIUser()), {
status: 404,
statusText: 'Not Found',
headers: { 'Content-Type': 'application/json' },
}),
),
)
const store = useUsersStore()
const resultUser = await store.fetchUserIfMissing({ id: '1' })
expect(resultUser).to.be.null
})
it('Should throw if no identifier is provided', async () => {
const store = useUsersStore()
await expect(
async () => await store.fetchUserIfMissing({}),
).rejects.to.throw(TypeError)
await expect(
async () => await store.fetchUserIfMissing(),
).rejects.to.throw(TypeError)
})
})
describe('relationships', () => {
it.each(['Friends', 'Followers'])('fetch%s', async (group) => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify([
mockMastoAPIUser({
screen_name: 'snake',
name: 'John',
id: '2',
}),
mockMastoAPIUser({
screen_name: 'zero',
name: 'David Oh',
id: '3',
}),
]),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify([
mockMastoAPIUser({
screen_name: 'sigint',
name: 'Mr.Anderson',
id: '4',
}),
mockMastoAPIUser({
screen_name: 'paramedic',
name: 'Dr.Clark',
id: '5',
}),
]),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
store.addNewUsers({ timestamp: 1, data: mockUser() })
const urlGroup = group === 'Friends' ? 'Following' : group
const us = store.users.get(userId)
await store[`fetch${group}`](userId)
expect(mockFetch).to.have.been.calledWith(
PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, {
limit: 20,
withRelationships: true,
}),
DEFAULT_OPTIONS('GET'),
)
expect(
store.relationshipsLists[group.toLowerCase()].get(us),
).to.have.length(2)
await store[`fetch${group}`](userId)
expect(mockFetch).to.have.been.calledWith(
PUBLIC_API[`MASTODON_${urlGroup.toUpperCase()}_URL`](userId, {
maxId: '3',
limit: 20,
withRelationships: true,
}),
DEFAULT_OPTIONS('GET'),
)
expect(
store.relationshipsLists[group.toLowerCase()].get(us),
).to.have.length(4)
})
it.each(['Mutes', 'Blocks'])('fetch%s', async (group) => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify([
mockMastoAPIUser({
screen_name: 'snake',
name: 'John',
id: '2',
}),
mockMastoAPIUser({
screen_name: 'zero',
name: 'David Oh',
id: '3',
}),
]),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify([
mockMastoAPIUser({
screen_name: 'sigint',
name: 'Mr.Anderson',
id: '4',
}),
mockMastoAPIUser({
screen_name: 'paramedic',
name: 'Dr.Clark',
id: '5',
}),
]),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
store.addNewUsers({ timestamp: 1, data: mockUser() })
const us = store.users.get(userId)
store.currentUser = us
const ids = group === 'Mutes' ? 'muteIds' : 'blockIds'
await store[`fetch${group}`]({ reset: true })
expect(mockFetch).to.have.been.calledWith(
USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({
withRelationships: true,
}),
DEFAULT_OPTIONS('GET'),
)
expect(us[ids]).to.have.length(2)
await store[`fetch${group}`]()
expect(mockFetch).to.have.been.calledWith(
USER_API[`MASTODON_USER_${group.toUpperCase()}_URL`]({
withRelationships: true,
}),
DEFAULT_OPTIONS('GET'),
)
expect(us[ids]).to.have.length(4)
})
it('fetchDomainMutes', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(['example.com', 'example.org']), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
store.addNewUsers({ timestamp: 1, data: mockUser() })
const us = store.users.get(userId)
store.currentUser = us
await store.fetchDomainMutes()
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_DOMAIN_BLOCKS_URL,
DEFAULT_OPTIONS('GET'),
)
expect(us.domainMutes).to.have.eql(
new Set(['example.com', 'example.org']),
)
})
it('fetchInLists', async () => {
const inLists = [
{ exclusive: false, id: '1', title: 'Operatives' },
{ exclusive: true, id: '2', title: 'Agents' },
]
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(inLists), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
store.addNewUsers({
timestamp: 1,
data: [
mockUser(),
{ ...mockUser({ name: 'John', screen_name: 'snake', id: '2' }) },
{ ...mockUser({ name: 'David Oh', screen_name: 'zero', id: '3' }) },
],
})
const us = store.users.get(userId)
store.currentUser = us
await store.fetchUserInLists('2')
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_USER_IN_LISTS('2'),
DEFAULT_OPTIONS('GET'),
)
expect(store.users.get('2').inLists).to.have.eql(inLists)
})
})
})
describe('misc updates', () => {
it('updateUserAdminData', () => {
const store = useUsersStore()
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
const adminData = { is_active: true, tags: ['one', 'two'] }
store.updateUserAdminData(userId, adminData)
const userData = store.users.get(userId)
expect(userData.deactivated).to.eql(false)
expect(userData.tags).to.eql(new Set(['one', 'two']))
})
it('updateRight', () => {
const store = useUsersStore()
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
store.updateRight(userId, 'right1', true)
store.updateRight(userId, 'right2', false)
const userData = store.users.get(userId)
expect(userData.rights.right1).to.eql(true)
expect(userData.rights.right2).to.eql(false)
})
it('clearFollowLists', () => {
const store = useUsersStore()
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
const userData = store.users.get(userId)
store.relationshipsLists.friends.get(userData).add('2')
store.relationshipsLists.friends.get(userData).add('3')
store.relationshipsLists.followers.get(userData).add('4')
store.relationshipsLists.followers.get(userData).add('5')
store.clearFollowLists(userId)
expect(store.relationshipsLists.friends.get(userData)).to.have.length(0)
expect(store.relationshipsLists.followers.get(userData)).to.have.length(0)
})
})
describe('login/logout', () => {
describe('login', () => {
it('normal login', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIUser()), {
headers: { 'Content-Type': 'application/json' },
}),
),
)
const spies = [
// Misc initialization
vi.spyOn(useSyncConfigStore(), 'initSyncConfig'),
vi.spyOn(useUserHighlightStore(), 'initUserHighlight'),
vi.spyOn(useInterfaceStore(), 'applyTheme'),
vi.spyOn(useInterfaceStore(), 'onLogin'),
vi.spyOn(useEmojiStore(), 'fetchEmoji'),
// Timeline / Notifications
vi.spyOn(useNotificationsStore(), 'activate'),
vi.spyOn(useTimelinesStore(), 'activatePersistents'),
// Fetchers
vi.spyOn(useChatsStore(), 'startFetching'),
vi.spyOn(useListsStore(), 'startFetching'),
vi.spyOn(useAnnouncementsStore(), 'startFetching'),
vi.spyOn(useBookmarkFoldersStore(), 'startFetching'),
vi.spyOn(useStreamingStore(), 'initSocket'),
]
spies.forEach((spy) => {
spy.mockImplementation(async () => {
/* no-op */
})
})
useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true
useMergedConfigStore().mergedConfig = { useStreamingApi: true }
const store = useUsersStore()
// Adding some users to verify they are getting cleaned afterwards
store.addNewUsers({
data: [
mockUser(),
{ ...mockUser({ name: 'John', screen_name: 'snake' }) },
{ ...mockUser({ name: 'David Oh', screen_name: 'zero' }) },
],
timestamp: 2000,
})
expect(store.loggedIn).to.eql(false)
await store.loginUser('ACCESS_TOKEN')
expect(store.loggedIn).to.eql(true)
// We should be in the store ourselves
expect(store.users).to.have.length(1)
expect(store.usersByName).to.have.length(1)
expect(store.usersByURL).to.have.length(1)
expect(store.relationships).to.have.length(0)
expect(store.lastLoginName).to.eql(userScreenName)
spies.forEach((spy, index) => {
expect(spy, `Spy ${index} has failed`).to.have.been.called
})
})
it('bad credentials', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(mockMastoAPIUser()), {
status: 403,
statusText: 'Forbidden',
headers: { 'Content-Type': 'application/json' },
}),
),
)
const spies = [vi.spyOn(useOAuthStore(), 'clearToken')]
spies.forEach((spy) => {
spy.mockImplementation(async () => {
/* no-op */
})
})
const store = useUsersStore()
const exec = async () => {
await store.loginUser('ACCESS_TOKEN')
}
await expect(exec).rejects.to.throw(Error)
expect(store.loggedIn).to.eql(false)
spies.forEach((spy, index) => {
expect(spy, `Spy ${index} has failed`).to.have.been.called
})
})
})
describe('logout', () => {
it('normal logout', async () => {
const revokeApi = vi
.fn()
.mockResolvedValueOnce(
// Ensure APP
new Response(JSON.stringify('ok'), {
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
// Revoke Token
new Response(JSON.stringify('ok'), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', revokeApi)
const spies = [
// Misc initialization
vi.spyOn(useStatusesStore(), 'resetStatuses'),
vi.spyOn(useInterfaceStore(), 'onLogout'),
// Timeline / Notifications
vi.spyOn(useNotificationsStore(), 'deactivate'),
vi.spyOn(useTimelinesStore(), 'deactivateAll'),
// Fetchers
vi.spyOn(useChatsStore(), 'resetChats'),
vi.spyOn(useListsStore(), 'stopFetching'),
vi.spyOn(useAnnouncementsStore(), 'stopFetching'),
vi.spyOn(useBookmarkFoldersStore(), 'stopFetching'),
vi.spyOn(useStreamingStore(), 'stopSocket'),
]
spies.forEach((spy) => {
spy.mockImplementation(async () => {
/* no-op */
})
})
useInstanceCapabilitiesStore().pleromaChatMessagesAvailable = true
useMergedConfigStore().mergedConfig = { useStreamingApi: true }
const store = useUsersStore()
store.currentUser = mockUser()
// Adding some users to verify they are getting cleaned afterwards
store.addNewUsers({
data: [
mockUser(),
{
...mockUser({ name: 'John', screen_name: 'snake' }),
relationship: { id: userId, following: true },
},
{ ...mockUser({ name: 'David Oh', screen_name: 'zero' }) },
],
timestamp: 2000,
})
expect(store.loggedIn).to.eql(true)
await store.logout()
expect(store.loggedIn).to.eql(false)
expect(store.lastLoginName).to.eql(null)
expect(revokeApi).to.have.been.called
expect(store.users).to.have.length(0)
expect(store.usersByName).to.have.length(0)
expect(store.usersByURL).to.have.length(0)
expect(store.relationships).to.have.length(0)
spies.forEach((spy, index) => {
expect(spy, `Spy ${index} has failed`).to.have.been.called
})
})
})
})
describe('actions', () => {
describe('follow', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('instant follow case', async () => {
const followApi = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: true,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', followApi)
const store = useUsersStore()
await store.followUser(userId)
expect(followApi).to.have.been.called
expect(store.followPollers).to.have.length(0)
expect(store.followPollersAttempts).to.have.length(0)
expect(followApi).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: '{}',
...DEFAULT_OPTIONS(),
},
)
})
it('delayed follow case', async () => {
const followApi = vi
.fn()
.mockResolvedValueOnce(
// Follow
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
.mockResolvedValueOnce(
// Check relationship
new Response(
JSON.stringify({
id: userId,
following: true,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', followApi)
const store = useUsersStore()
await store.followUser(userId)
expect(followApi).to.have.been.called
expect(store.followPollers).to.have.length(1)
expect(store.followPollersAttempts).to.have.length(1)
expect(store.followPollersAttempts.get(userId)).to.eql(3)
await vi.runAllTimersAsync()
expect(followApi).to.have.been.called
expect(store.followPollers).to.have.length(0)
expect(store.followPollersAttempts).to.have.length(0)
})
it('unresolved follow case', async () => {
const followApi = vi.fn().mockResolvedValueOnce(
// Follow
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
followApi // mockImplementation because we need to re-create Response
.mockImplementation(
() =>
// Check relationship
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', followApi)
const store = useUsersStore()
await store.followUser(userId)
expect(store.followPollers).to.have.length(1)
expect(store.followPollersAttempts).to.have.length(1)
expect(store.followPollersAttempts.get(userId)).to.eql(3)
await vi.runAllTimersAsync()
expect(store.followPollersAttempts.get(userId)).to.eql(2)
await vi.runAllTimersAsync()
expect(store.followPollersAttempts.get(userId)).to.eql(1)
await vi.runAllTimersAsync()
expect(store.followPollers).to.have.length(0)
expect(store.followPollersAttempts).to.have.length(0)
expect(followApi).to.have.callCount(4) // 1 follow + 3 retries
})
})
it('unfollow', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.unfollowUser(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_UNFOLLOW_URL(userId),
DEFAULT_OPTIONS(),
)
})
it('subscribe', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
subscribing: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.subscribeUser(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: JSON.stringify({ notify: true }),
...DEFAULT_OPTIONS(),
},
)
})
it('unsubscribe', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
subscribing: false,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.unsubscribeUser(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: JSON.stringify({ notify: false }),
...DEFAULT_OPTIONS(),
},
)
})
it('showReblogs', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
showing_reblogs: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.showReblogs(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: JSON.stringify({ reblogs: true }),
...DEFAULT_OPTIONS(),
},
)
})
it('hideReblogs', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
id: userId,
following: false,
requested: true,
showing_reblogs: false,
}),
{
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
await store.hideReblogs(userId)
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_FOLLOW_URL(userId),
{
body: JSON.stringify({ reblogs: false }),
...DEFAULT_OPTIONS(),
},
)
})
it.each([
'unmute',
'unblock',
'removeUserFromFollowers',
])('%s', async (action) => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: userId }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
const { storeAction, apiUrl } = actionKeys(action)
await store[storeAction](userId)
console.log(apiUrl)
expect(mockFetch).to.have.been.calledWith(
USER_API[apiUrl](userId),
DEFAULT_OPTIONS(),
)
})
describe.each(['mute', 'block'])('%s', (action) => {
it('normal', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: userId }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation(
async () => {
/* no-op */
},
)
- vi.spyOn(useTimelinesStore(), 'wipeUserStatuses').mockImplementation(
+ vi.spyOn(useTimelinesStore(), 'wipeStatuses').mockImplementation(
async () => {
/* no-op */
},
)
const store = useUsersStore()
const { storeAction, apiUrl } = actionKeys(action)
await store[storeAction](userId)
expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), {
body: '{}',
...DEFAULT_OPTIONS(),
})
})
it('with expiration', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: userId }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(useStatusesStore(), 'wipeUserStatuses').mockImplementation(
async () => {
/* no-op */
},
)
- vi.spyOn(useTimelinesStore(), 'wipeUserStatuses').mockImplementation(
+ vi.spyOn(useTimelinesStore(), 'wipeStatuses').mockImplementation(
async () => {
/* no-op */
},
)
const store = useUsersStore()
const { storeAction, apiUrl } = actionKeys(action)
await store[storeAction](userId, 20)
const argument = action === 'mute' ? 'expires_in' : 'duration'
expect(mockFetch).to.have.been.calledWith(USER_API[apiUrl](userId), {
body: `{"${argument}":20}`,
...DEFAULT_OPTIONS(),
})
})
})
it.each(['mute', 'unmute'])('%s domain', async (action) => {
const mockFetch = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: userId }), {
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', mockFetch)
const store = useUsersStore()
store.currentUser = mockUser()
store.currentUser.domainMutes = new Set()
if (action === 'unmute') {
store.currentUser.domainMutes.add('example.com')
}
await store[action + 'Domain']('example.com')
expect(mockFetch).to.have.been.calledWith(
USER_API.MASTODON_DOMAIN_BLOCKS_URL,
{
body: JSON.stringify({ domain: 'example.com' }),
...DEFAULT_OPTIONS(action === 'mute' ? 'POST' : 'DELETE'),
},
)
if (action === 'mute') {
expect(store.currentUser.domainMutes).to.include('example.com')
} else {
expect(store.currentUser.domainMutes).to.not.include('example.com')
}
})
it.each([
'muteDomain',
'unmuteDomain',
'muteUser',
'unmuteUser',
'blockUser',
'unblockUser',
])('%ss', async (action) => {
const store = useUsersStore()
store[action] = vi.fn().mockResolvedValue(async () => {
/* no-op */
})
store[action + 's'](['1', '2', '3'])
expect(store[action]).to.have.been.calledWith('1')
expect(store[action]).to.have.been.calledWith('2')
expect(store[action]).to.have.been.calledWith('3')
})
})
describe('getters', () => {
it('relationship returns a placeholder if relationship info is missing', () => {
const store = useUsersStore()
expect(store.relationship(userId)).to.eql({ id: userId, loading: true })
})
it('relationship returns a placeholder if relationship info is missing while user is present', () => {
const store = useUsersStore()
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
expect(store.relationship(userId)).to.eql({ id: userId, loading: true })
})
it('findUser returns user with matching id', () => {
const store = useUsersStore()
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
expect(store.findUser(userId).id).to.eql(userId)
})
it('findUserByName returns user with matching screen_name', () => {
const store = useUsersStore()
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
expect(store.findUserByName(mockUser().screen_name).id).to.eql(userId)
})
it('findUserByName returns user with matching url', () => {
const store = useUsersStore()
store.addNewUsers({ data: [mockUser()], timestamp: 1 })
expect(store.findUserByUrl(mockUser().url).id).to.eql(userId)
})
})
})

File Metadata

Mime Type
text/x-diff
Expires
Mon, Nov 2, 9:20 AM (2 h, 34 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1804160
Default Alt Text
(109 KB)

Event Timeline