Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85832866
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
109 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline