Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85827586
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
100 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/App.js b/src/App.js
index 2854f7dfc5..ed3d43e4a5 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,312 +1,312 @@
import { throttle } from 'lodash'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import DesktopNav from 'src/components/desktop_nav/desktop_nav.vue'
import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import GlobalError from 'src/components/global_error/global_error.vue'
import GlobalNoticeList from 'src/components/global_notice_list/global_notice_list.vue'
import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MobileNav from 'src/components/mobile_nav/mobile_nav.vue'
import MobilePostStatusButton from 'src/components/mobile_post_status_button/mobile_post_status_button.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import UserPanel from 'src/components/user_panel/user_panel.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowHeight, windowWidth } from './services/window_utils/window_utils'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useInstanceStore } from 'src/stores/instance.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 { useShoutStore } from 'src/stores/shout.js'
import { useUsersStore } from 'src/stores/users.js'
// Helper to unwrap reactive proxies
window.toValue = (x) => JSON.parse(JSON.stringify(x))
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel: defineAsyncComponent(
() =>
import('src/components/who_to_follow_panel/who_to_follow_panel.vue'),
),
ShoutPanel: defineAsyncComponent(
() => import('src/components/shout_panel/shout_panel.vue'),
),
MediaModal: defineAsyncComponent(
() => import('src/components/media_modal/media_modal.vue'),
),
MobilePostStatusButton,
MobileNav,
DesktopNav,
SettingsModal: defineAsyncComponent(
() => import('src/components/settings_modal/settings_modal.vue'),
),
UpdateNotification: defineAsyncComponent(
() =>
import('src/components/update_notification/update_notification.vue'),
),
PostStatusModal: defineAsyncComponent(
() => import('src/components/post_status_modal/post_status_modal.vue'),
),
UserReportingModal: defineAsyncComponent(
() =>
import('src/components/user_reporting_modal/user_reporting_modal.vue'),
),
EditStatusModal: defineAsyncComponent(
() => import('src/components/edit_status_modal/edit_status_modal.vue'),
),
StatusHistoryModal: defineAsyncComponent(
() =>
import('src/components/status_history_modal/status_history_modal.vue'),
),
GlobalError,
GlobalNoticeList,
},
data: () => ({
mobileActivePanel: 'timeline',
updateMobileState: null,
updateScrollState: null,
}),
provide() {
return {
allowNonSquareEmoji: useMergedConfigStore().mergedConfig.nonSquareEmoji,
}
},
watch: {
themeApplied() {
this.removeSplash()
},
currentTheme() {
this.setThemeBodyClass()
},
layoutType() {
document.getElementById('modal').classList = ['-' + this.layoutType]
},
},
created() {
// Load the locale from the storage
const value = useMergedConfigStore().mergedConfig.interfaceLanguage
useI18nStore().setLanguage(value)
useEmojiStore().loadUnicodeEmojiData(value)
document.getElementById('modal').classList = ['-' + this.layoutType]
// Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200)
},
mounted() {
window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (this.themeApplied) {
this.setThemeBodyClass()
this.removeSplash()
}
getOrCreateServiceWorker()
},
unmounted() {
window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState)
},
computed: {
currentTheme() {
if (this.styleDataUsed) {
const styleMeta = this.styleDataUsed.find(
(x) => x.component === '@meta',
)
if (styleMeta !== undefined) {
return styleMeta.directives.name.replaceAll(' ', '-').toLowerCase()
}
}
return 'stock'
},
layoutModalClass() {
return '-' + this.layoutType
},
classes() {
return [
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown,
},
'-' + this.layoutType,
]
},
navClasses() {
const { navbarColumnStretch } = useMergedConfigStore().mergedConfig
return [
'-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : []),
]
},
userBackground() {
- return this.currentUser.background_image
+ return this.currentUser?.background_image
},
foreignProfileBackground() {
return (
useMergedConfigStore().mergedConfig.allowForeignUserBackground &&
useInterfaceStore().foreignProfileBackground
)
},
instanceBackground() {
return useMergedConfigStore().mergedConfig.hideInstanceWallpaper
? null
: this.instanceBackgroundUrl
},
background() {
return (
this.foreignProfileBackground ||
this.userBackground ||
this.instanceBackground
)
},
bgStyle() {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`,
}
}
},
shoutJoined() {
return useShoutStore().joined
},
isChats() {
return (
this.$route.name === 'chat' ||
this.$route.name === 'chats' ||
this.$route.name === 'conversation2'
)
},
isListEdit() {
return this.$route.name === 'lists-edit'
},
newPostButtonShown() {
if (this.isChats) return false
if (this.isListEdit) return false
return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton ||
this.layoutType === 'mobile'
)
},
shoutboxPosition() {
return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton || false
)
},
hideShoutbox() {
return this.isChats || useMergedConfigStore().mergedConfig.hideShoutbox
},
thirdColumnMode() {
return this.mergedConfig.thirdColumnMode
},
reverseSetting() {
return this.mergedConfig.sidebarRight
},
reverseLayout() {
if (this.layoutType !== 'wide') {
return this.reverseSetting
} else {
return this.thirdColumnMode === 'notifications'
? this.reverseSetting
: !this.reverseSetting
}
},
noSticky() {
return this.mergedConfig.disableStickyHeaders
},
showScrollbars() {
return this.mergedConfig.showScrollbars
},
scrollParent() {
return window /* this.$refs.appContentRef */
},
showInstanceSpecificPanel() {
return this.instanceSpecificPanelPresent && !this.mergedConfig.hideISP
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, [
'themeApplied',
'styleDataUsed',
'layoutType',
]),
...mapState(useUsersStore, ['currentUser']),
...mapState(useInstanceStore, ['styleDataUsed']),
...mapState(useInstanceCapabilitiesStore, [
'suggestionsEnabled',
'editingAvailable',
]),
...mapState(useInstanceStore, {
instanceBackgroundUrl: (store) => store.instanceIdentity.background,
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
},
methods: {
resizeHandler() {
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
},
scrollHandler() {
const scrollPosition =
this.scrollParent === window
? window.scrollY
: this.scrollParent.scrollTop
if (scrollPosition != 0) {
this.$refs.appContentRef.classList.add(['-scrolled'])
} else {
this.$refs.appContentRef.classList.remove(['-scrolled'])
}
},
setThemeBodyClass() {
const themeName = this.currentTheme
const classList = Array.from(document.body.classList)
const oldTheme = classList.filter((c) => c.startsWith('theme-'))
if (themeName !== null && themeName !== '') {
const newTheme = `theme-${themeName.toLowerCase()}`
// remove old theme reference if there are any
if (oldTheme.length) {
document.body.classList.replace(oldTheme[0], newTheme)
} else {
document.body.classList.add(newTheme)
}
} else {
// remove theme reference if non-V3 theme is used
document.body.classList.remove(...oldTheme)
}
},
removeSplash() {
document.querySelector('#status').textContent = this.$t(
'splash.fun_' + Math.ceil(Math.random() * 4),
)
const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove()
})
setTimeout(() => {
splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ
}, 600)
splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden')
},
},
}
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index 8252ddf2bd..f651869914 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -1,150 +1,150 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import {
WSConnectionStatus,
} from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faHome,
faInfoCircle,
faSearch,
faSignInAlt,
faSignOutAlt,
faTachometerAlt,
faUserPlus,
faPlug,
faPlugCircleXmark
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle,
faPlug,
faPlugCircleXmark
)
export default {
components: {
SearchBar,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data: () => ({
searchBarHidden: true,
supportsMask:
window.CSS?.supports &&
(window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')),
showingConfirmLogout: false,
}),
computed: {
enableMask() {
return this.supportsMask && this.logoMask
},
logoStyle() {
return {
visibility: this.enableMask ? 'hidden' : 'visible',
}
},
logoMaskStyle() {
return this.enableMask
? {
'mask-image': `url(${this.logo})`,
}
: {
'background-color': this.enableMask ? '' : 'transparent',
}
},
logoBgStyle() {
const mask = this.enableMask
? {}
: { 'background-color': this.enableMask ? '' : 'transparent' }
return {
margin: `${this.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0,
...mask,
}
},
...mapState(useInstanceStore, ['privateMode']),
...mapState(useInstanceStore, {
logoMask: (store) => store.instanceIdentity.logoMask,
logo: (store) => store.instanceIdentity.logo,
logoLeft: (store) => store.instanceIdentity.logoLeft,
logoMargin: (store) => store.instanceIdentity.logoMargin,
sitename: (store) => store.instanceIdentity.name,
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
...mapState(useUsersStore, ['currentUser']),
...mapState(useStreamingStore, {
streamingConnected: (store) => store.state === WSConnectionStatus.JOINED
}),
...mapState(useMergedConfigStore, ['mergedConfig']),
shouldConfirmLogout() {
return this.mergedConfig.modalOnLogout
},
streamingEnabled() {
return this.mergedConfig.useStreamingApi
},
streamingTooltip() {
if (this.streamingConnected) {
return this.$t('timeline.socket_reconnected')
} else {
return this.$t('timeline.socket_disconnected')
}
}
},
methods: {
scrollToTop() {
window.scrollTo(0, 0)
},
showConfirmLogout() {
this.showingConfirmLogout = true
},
hideConfirmLogout() {
this.showingConfirmLogout = false
},
logout() {
if (!this.shouldConfirmLogout) {
this.doLogout()
} else {
this.showConfirmLogout()
}
},
doLogout() {
this.$router.replace('/main/public')
- this.$store.dispatch('logout')
+ useUsersStore().logout()
this.hideConfirmLogout()
},
onSearchBarToggled(hidden) {
this.searchBarHidden = hidden
},
...mapActions(useInterfaceStore, ['openSettingsModal']),
},
}
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index 9a4c4c61d6..80cfc5326a 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -1,167 +1,167 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import NavigationPins from 'src/components/navigation/navigation_pins.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import {
countExtraNotifications,
unseenNotifications,
} from '../../services/notification_utils/notification_utils'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faBars,
faBell,
faCheckDouble,
faMinus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faBell, faBars, faArrowUp, faMinus, faCheckDouble)
const MobileNav = {
components: {
SideDrawer: defineAsyncComponent(
() => import('src/components/side_drawer/side_drawer.vue'),
),
Notifications: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
NavigationPins,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data: () => ({
notificationsCloseGesture: undefined,
notificationsOpen: false,
notificationsAtTop: true,
showingConfirmLogout: false,
}),
created() {
this.notificationsCloseGesture = GestureService.swipeGesture(
GestureService.DIRECTION_RIGHT,
() => this.closeMobileNotifications(true),
50,
)
},
computed: {
currentUser() {
return useUsersStore().currentUser
},
unseenNotifications() {
return unseenNotifications(
useMergedConfigStore().mergedConfig.notificationVisibility,
useMergedConfigStore().mergedConfig.ignoreInactionableSeen,
)
},
unseenNotificationsCount() {
return (
this.unseenNotifications.length +
countExtraNotifications(
this.$store,
useMergedConfigStore().mergedConfig,
useChatsStore().unreadChatsCount,
useAnnouncementsStore().unreadAnnouncementCount,
)
)
},
unseenCount() {
return this.unseenNotifications.length
},
unseenCountBadgeText() {
return `${this.unseenCount ? this.unseenCount : ''}`
},
hideSitename() {
return useInstanceStore().hideSitename
},
sitename() {
return useInstanceStore().name
},
isChat() {
return this.$route.name === 'chat'
},
shouldConfirmLogout() {
return useMergedConfigStore().mergedConfig.modalOnLogout
},
closingDrawerMarksAsSeen() {
return useMergedConfigStore().mergedConfig.closingDrawerMarksAsSeen
},
...mapState(useAnnouncementsStore, ['unreadAnnouncementCount']),
...mapState(useMergedConfigStore, {
pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems).has('chats'),
}),
...mapState(useChatsStore, ['unreadChatsCount']),
},
methods: {
toggleMobileSidebar() {
this.$refs.sideDrawer.toggleDrawer()
},
openMobileNotifications() {
this.notificationsOpen = true
},
closeMobileNotifications(markRead) {
if (this.notificationsOpen) {
// make sure to mark notifs seen only when the notifs were open and not
// from close-calls.
this.notificationsOpen = false
if (markRead && this.closingDrawerMarksAsSeen) {
this.markNotificationsAsSeen()
}
}
},
notificationsTouchStart(e) {
GestureService.beginSwipe(e, this.notificationsCloseGesture)
},
notificationsTouchMove(e) {
GestureService.updateSwipe(e, this.notificationsCloseGesture)
},
scrollToTop() {
window.scrollTo(0, 0)
},
scrollMobileNotificationsToTop() {
this.$refs.mobileNotifications.scrollTo(0, 0)
},
showConfirmLogout() {
this.showingConfirmLogout = true
},
hideConfirmLogout() {
this.showingConfirmLogout = false
},
logout() {
if (!this.shouldConfirmLogout) {
this.doLogout()
} else {
this.showConfirmLogout()
}
},
doLogout() {
this.$router.replace('/main/public')
- this.$store.dispatch('logout')
+ useUsersStore().logout()
this.hideConfirmLogout()
},
markNotificationsAsSeen() {
this.$store.dispatch('markNotificationsAsSeen')
},
onScroll({ target: { scrollTop, clientHeight, scrollHeight } }) {
this.notificationsAtTop = scrollTop > 0
},
},
watch: {
$route() {
// handles closing notificaitons when you press any router-link on the
// notifications.
this.closeMobileNotifications()
},
},
}
export default MobileNav
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
index b30cd2f132..b0b72a4e9c 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.js
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -1,186 +1,186 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Mfa from './mfa.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens'
import { useUsersStore } from 'src/stores/users.js'
import {
addAlias,
changeEmail,
changePassword,
deleteAccount,
deleteAlias,
listAliases,
moveAccount,
} from 'src/api/user.js'
import localeService from 'src/services/locale/locale.service.js'
const SecurityTab = {
data() {
return {
newEmail: '',
changeEmailError: false,
changeEmailPassword: '',
changedEmail: false,
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
changePasswordInputs: ['', '', ''],
changedPassword: false,
changePasswordError: false,
moveAccountTarget: '',
moveAccountPassword: '',
movedAccount: false,
moveAccountError: false,
aliases: [],
listAliasesError: false,
addAliasTarget: '',
addedAlias: false,
addAliasError: false,
}
},
created() {
useOAuthTokensStore().fetchTokens()
this.fetchAliases()
},
components: {
ProgressButton,
Mfa,
Checkbox,
},
computed: {
user() {
return useUsersStore().currentUser
},
pleromaExtensionsAvailable() {
return useInstanceCapabilitiesStore().pleromaExtensionsAvailable
},
oauthTokens() {
return useOAuthTokensStore().tokens.map((oauthToken) => {
return {
id: oauthToken.id,
appName: oauthToken.app_name,
validUntil: new Date(oauthToken.valid_until).toLocaleDateString(
localeService.internalToBrowserLocale(this.$i18n.locale),
),
}
})
},
},
methods: {
confirmDelete() {
this.deletingAccount = true
},
deleteAccount() {
deleteAccount({
credentials: useOAuthStore().token,
password: this.deleteAccountConfirmPasswordInput,
}).then(({ data: res }) => {
if (res.status === 'success') {
- this.$store.dispatch('logout')
+ useUsersStore().logout()
this.$router.push({ name: 'root' })
} else {
this.deleteAccountError = res.error
}
})
},
changePassword() {
const params = {
password: this.changePasswordInputs[0],
newPassword: this.changePasswordInputs[1],
newPasswordConfirmation: this.changePasswordInputs[2],
credentials: useOAuthStore().token,
}
changePassword(params).then(({ data: res }) => {
if (res.status === 'success') {
this.changedPassword = true
this.changePasswordError = false
this.logout()
} else {
this.changedPassword = false
this.changePasswordError = res.error
}
})
},
changeEmail() {
const params = {
email: this.newEmail,
password: this.changeEmailPassword,
credentials: useOAuthStore().token,
}
changeEmail(params).then(({ data: res }) => {
if (res.status === 'success') {
this.changedEmail = true
this.changeEmailError = false
} else {
this.changedEmail = false
this.changeEmailError = res.error
}
})
},
moveAccount() {
const params = {
targetAccount: this.moveAccountTarget,
password: this.moveAccountPassword,
credentials: useOAuthStore().token,
}
moveAccount(params).then(({ data: res }) => {
if (res.status === 'success') {
this.movedAccount = true
this.moveAccountError = false
} else {
this.movedAccount = false
this.moveAccountError = res.error
}
})
},
removeAlias(alias) {
deleteAlias({
alias,
credentials: useOAuthStore().token,
}).then(() => this.fetchAliases())
},
addAlias() {
addAlias({
alias: this.addAliasTarget,
credentials: useOAuthStore().token,
})
.then(() => {
this.addedAlias = true
this.addAliasError = false
this.addAliasTarget = ''
})
.catch((error) => {
this.addedAlias = false
this.addAliasError = error
})
.then(() => this.fetchAliases())
},
fetchAliases() {
listAliases({
credentials: useOAuthStore().token,
})
.then(({ data: res }) => {
this.aliases = res.aliases
this.listAliasesError = false
})
.catch((error) => {
this.listAliasesError = error.error
})
},
logout() {
- this.$store.dispatch('logout')
+ useUsersStore().logout()
this.$router.replace('/')
},
revokeToken(id) {
if (window.confirm(`${this.$i18n.t('settings.revoke_token')}?`)) {
useOAuthTokensStore().revokeToken(id)
}
},
},
}
export default SecurityTab
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 28c7e040f8..c88d120db1 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,584 +1,584 @@
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 { 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: {
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,
}
},
computed: {
showReasonMutedThread() {
return (
(this.status.thread_muted || this.status.reblog?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
const user = this.statusoid.user
return highlightClass(user)
},
userClass() {
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
return highlightClass(user)
},
deleted() {
return this.statusoid.deleted
},
repeaterStyle() {
const user = this.statusoid.user
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userStyle() {
if (this.noHeading) return
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userProfileLink() {
return this.generateUserProfileLink(
this.status.user.id,
this.status.user.screen_name,
)
},
replyProfileLink() {
if (this.isReply) {
const user = useUsersStore().findUser(this.status.in_reply_to_user_id)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet() {
return !!this.statusoid.retweeted_status
},
retweeterUser() {
return this.statusoid.user
},
retweeter() {
return this.statusoid.user.name || this.statusoid.user.screen_name_ui
},
retweeterHtml() {
return this.statusoid.user.name
},
retweeterProfileLink() {
return this.generateUserProfileLink(
this.statusoid.user.id,
this.statusoid.user.screen_name,
)
},
status() {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository() {
// NOTE: Consider to replace status with statusFromGlobalRepository
return useStatusesStore().allStatuses.get(this.status.id)
},
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.status.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.status.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.status.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.status.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.statusoid.user.id === this.currentUser.id) return false
+ if (this.statusoid.user.id === this.currentUser?.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
- if (this.statusoid.user.id === this.currentUser.id) return false
+ if (this.statusoid.user.id === this.currentUser?.id) return false
const { status } = this
const { reblog } = status
const relationship = useUsersStore().relationship(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 { status } = this
const { reblog } = status
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && status.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)) &&
// 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.status.in_reply_to_status_id && this.status.in_reply_to_user_id
)
},
replyToName() {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = useUsersStore().findUser(this.status.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.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.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.statusFromGlobalRepository.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.status.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)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.focused &&
this.statusFromGlobalRepository.rebloggedBy &&
this.statusFromGlobalRepository.rebloggedBy.length !== num
) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (
this.focused &&
this.statusFromGlobalRepository.favoritedBy &&
this.statusFromGlobalRepository.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (suspend) {
this.$emit('suspendableStateChange', { id: this.statusoid.id, suspend })
},
},
}
export default Status
diff --git a/src/components/status_action_buttons/buttons_definitions.js b/src/components/status_action_buttons/buttons_definitions.js
index b13bed0337..47b1baaf2b 100644
--- a/src/components/status_action_buttons/buttons_definitions.js
+++ b/src/components/status_action_buttons/buttons_definitions.js
@@ -1,348 +1,349 @@
import { useEditStatusStore } from 'src/stores/editStatus.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 { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
const PRIVATE_SCOPES = new Set(['private', 'direct'])
const PUBLIC_SCOPES = new Set(['public', 'unlisted'])
export const BUTTONS = [
{
// =========
// REPLY
// =========
name: 'reply',
label: 'tool_tip.reply',
icon: 'reply',
active: ({ replying }) => replying,
counter: ({ status }) => status.replies_count,
anon: true,
anonLink: true,
toggleable: true,
closeIndicator: 'times',
activeIndicator: null,
action({ emit }) {
emit('toggleReplying')
return Promise.resolve()
},
},
{
// =========
// REPEAT
// =========
name: 'retweet',
label: ({ status }) =>
status.repeated ? 'tool_tip.unrepeat' : 'tool_tip.repeat',
- icon({ status, currentUser }) {
+ icon({ status, loggedIn, currentUser }) {
if (
- currentUser.id !== status.user.id &&
+ loggedIn &&
+ status.user.id !== currentUser.id &&
PRIVATE_SCOPES.has(status.visibility)
) {
return 'lock'
}
return 'retweet'
},
animated: true,
active: ({ status }) => status.repeated,
counter: ({ status }) => status.repeat_num,
anonLink: true,
interactive: ({ status, currentUser }) =>
!!currentUser &&
(currentUser.id === status.user.id ||
!PRIVATE_SCOPES.has(status.visibility)),
toggleable: true,
confirm: ({ status }) =>
!status.repeated && useMergedConfigStore().mergedConfig.modalOnRepeat,
confirmStrings: {
title: 'status.repeat_confirm_title',
body: 'status.repeat_confirm',
confirm: 'status.repeat_confirm_accept_button',
cancel: 'status.repeat_confirm_cancel_button',
},
action({ status }) {
if (!status.repeated) {
return useStatusesStore().retweet(status.id)
} else {
return useStatusesStore().unretweet(status.id)
}
},
},
{
// =========
// FAVORITE
// =========
name: 'favorite',
label: ({ status }) =>
status.favorited ? 'tool_tip.unfavorite' : 'tool_tip.favorite',
icon: ({ status }) =>
status.favorited ? ['fas', 'star'] : ['far', 'star'],
animated: true,
active: ({ status }) => status.favorited,
counter: ({ status }) => status.fave_num,
anonLink: true,
toggleable: true,
action({ status }) {
if (!status.favorited) {
return useStatusesStore().favorite(status.id)
} else {
return useStatusesStore().unfavorite(status.id)
}
},
},
{
// =========
// EMOJI REACTIONS
// =========
name: 'emoji',
label: 'tool_tip.add_reaction',
icon: ['far', 'face-smile-beam'],
interactive: () => true,
active: ({ emojiPickerShown }) => emojiPickerShown,
toggleable: true,
anonLink: true,
},
{
// =========
// MUTE
// =========
name: 'mute',
icon: 'eye-slash',
label: 'status.mute_ellipsis',
if: ({ loggedIn }) => loggedIn,
toggleable: false,
dropdown: true,
action({ status, emit }) {
/* prevent hiding */
},
},
{
// =========
// PIN STATUS
// =========
name: 'pin',
icon: 'thumbtack',
label: ({ status }) => (status.pinned ? 'status.unpin' : 'status.pin'),
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
status.user.id === currentUser.id &&
PUBLIC_SCOPES.has(status.visibility)
)
},
action({ status }) {
if (status.pinned) {
return useStatusesStore().unpinStatus(status.id)
} else {
return useStatusesStore().pinStatus(status.id)
}
},
},
{
// =========
// BOOKMARK
// =========
name: 'bookmark',
icon: ({ status }) =>
status.bookmarked ? ['fas', 'bookmark'] : ['far', 'bookmark'],
toggleable: true,
active: ({ status }) => status.bookmarked,
label: ({ status }) =>
status.bookmarked ? 'status.unbookmark' : 'status.bookmark',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
if (status.bookmarked) {
return useStatusesStore().unbookmark(status.id)
} else {
return useStatusesStore().bookmark(status.id)
}
},
},
{
// =========
// EDIT HISTORY
// =========
name: 'editHistory',
icon: 'history',
label: 'status.status_history',
if({ status }) {
return (
useInstanceCapabilitiesStore().editingAvailable &&
status.edited_at !== null
)
},
action({ status }) {
const originalStatus = { ...status }
const stripFieldsList = [
'attachments',
'created_at',
'emojis',
'text',
'raw_html',
'nsfw',
'poll',
'summary',
'summary_raw_html',
]
stripFieldsList.forEach((p) => delete originalStatus[p])
useStatusHistoryStore().openStatusHistoryModal(originalStatus)
return Promise.resolve()
},
},
{
// =========
// EDIT
// =========
name: 'edit',
icon: 'pen',
label: 'status.edit',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
useInstanceCapabilitiesStore().editingAvailable &&
status.user.id === currentUser.id
)
},
action({ status }) {
return useStatusesStore()
.fetchStatusSource(status.id)
.then((data) =>
useEditStatusStore().openEditStatusModal({
statusId: status.id,
statusSubject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: status.nsfw,
statusPoll: status.poll,
statusFiles: [...status.attachments],
statusVisibility: status.visibility,
statusContentType: data.content_type,
}),
)
},
},
{
// =========
// OPEN IN CHAT VIEW
// =========
name: 'chat_view',
icon: 'comments',
label: 'status.open_in_chat_view',
if({ chatView }) {
return !chatView
},
action({ router, status }) {
router.push({ name: 'conversation2', params: { statusId: status.id } })
},
},
{
// =========
// OPEN IN THREAD VIEW
// =========
name: 'thread_view',
icon: 'list',
label: 'status.open_in_thread_view',
if({ chatView }) {
return chatView
},
action({ router, status }) {
router.push({ name: 'conversation', params: { id: status.id } })
},
},
{
// =========
// DELETE
// =========
name: 'delete',
icon: 'times',
label: 'status.delete',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
confirm: () => useMergedConfigStore().mergedConfig.modalOnDelete,
confirmStrings: {
title: 'status.delete_confirm_title',
body: 'status.delete_confirm',
confirm: 'status.delete_confirm_accept_button',
cancel: 'status.delete_confirm_cancel_button',
},
action({ status }) {
return useStatusesStore().deleteStatus(status.id)
},
},
{
// =========
// CHANGE SCOPE
// =========
name: 'changeScope',
icon: 'eye',
label: 'status.admin_change_scope',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
toggleable: false,
dropdown: true,
action({ status, emit }) {
/* prevent hiding */
},
},
{
// =========
// SHARE/COPY
// =========
name: 'share',
icon: 'share-alt',
label: 'status.copy_link',
action({ status, router }) {
navigator.clipboard.writeText(
[
useInstanceStore().server,
router.resolve({ name: 'conversation', params: { id: status.id } })
.href,
].join(''),
)
return Promise.resolve()
},
},
{
// =========
// EXTERNAL
// =========
name: 'external',
icon: 'external-link-alt',
label: 'status.external_source',
link: ({ status }) => status.external_url,
},
{
// =========
// REPORT
// =========
name: 'report',
icon: 'flag',
label: 'user_card.report',
if: ({ loggedIn }) => loggedIn,
action({ status }) {
useReportsStore().openUserReportingModal({
userId: status.user.id,
statusIds: [status.id],
})
return Promise.resolve()
},
},
].map((button) => {
return Object.fromEntries(
Object.entries(button).map(([k, v]) => [
k,
typeof v === 'function' || k === 'name' ? v : () => v,
]),
)
})
diff --git a/src/stores/statuses.js b/src/stores/statuses.js
index fa1198a937..9897410a56 100644
--- a/src/stores/statuses.js
+++ b/src/stores/statuses.js
@@ -1,632 +1,632 @@
import { defineStore } from 'pinia'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js'
import {
fetchEmojiReactions,
fetchFavoritedByUsers,
fetchRebloggedByUsers,
fetchScrobbles,
fetchStatus,
fetchStatusHistory,
fetchStatusSource,
search2,
} from 'src/api/public.js'
import {
bookmarkStatus,
deleteStatus,
favorite,
muteConversation,
pinOwnStatus,
reactWithEmoji,
retweet,
unbookmarkStatus,
unfavorite,
unmuteConversation,
unpinOwnStatus,
unreactWithEmoji,
unretweet,
} from 'src/api/user.js'
export const defaultState = () => ({
allStatuses: new Map(),
timestamps: new WeakMap(),
scrobblesNextFetch: {},
conversations: new Map(),
favorites: new Set(),
socket: null,
})
const getLatestScrobble = (user) => {
const scrobblesSupport =
useInstanceCapabilitiesStore().pleromaScrobblesAvailable
if (!scrobblesSupport || !user.name || user.id === 'undefined') {
return
}
if (
this.scrobblesNextFetch[user.id] &&
this.scrobblesNextFetch[user.id] > Date.now()
) {
return
}
this.scrobblesNextFetch[user.id] = Date.now() + 24 * 60 * 60 * 1000
if (!scrobblesSupport) return
fetchScrobbles({ accountId: user.id })
.then(({ data: scrobbles }) => {
if (scrobbles?.error) {
useInstanceCapabilitiesStore().set('pleromaScrobblesAvailable', false)
return
}
if (scrobbles.length > 0) {
user.latestScrobble = scrobbles[0]
this.scrobblesNextFetch[user.id] = Date.now() + 60 * 1000
}
})
.catch((e) => {
console.warn('cannot fetch scrobbles', e)
})
}
export const useStatusesStore = defineStore('statuses', {
state: defaultState,
actions: {
// Init
attachSocket() {
const et = new EventTarget()
const handleUpdate = ({ data, timestamp }) =>
this.addNewStatuses({ statuses: data, timestamp })
const handleDelete = ({ data }) =>
data.forEach((id) => this.setDeleted(id))
const socket = {
et,
handlers: {
handleUpdate,
handleDelete,
},
}
et.addEventListener('update', handleUpdate)
et.addEventListener('status.update', handleUpdate)
et.addEventListener('delete', handleDelete)
useStreamingStore().addSubscriber(socket)
this.socket = socket
},
resetStatuses() {
this.socket.et.removeEventListener('update', this.socket.handleUpdate)
this.socket.et.removeEventListener(
'status.update',
this.socket.handleUpdate,
)
this.socket.et.removeEventListener('delete', this.socket.handleDelete)
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
this[key] = value
})
},
addNewStatuses({ statuses, timestamp }) {
// Sanity check
if (!Array.isArray(statuses)) {
throw new TypeError("Statuses aren't an array!")
}
// addStatus should always return "main" status,
// not "sub-status" i.e. retweeted/quoted/liked status
// in case of likes (which are not statuses) it should return null
const addStatus = (data) => {
getLatestScrobble(data.user)
const [status] = this.mergeOrAdd(this.allStatuses, data)
// Add to conversation
const conversations = this.conversations
const conversationId = status.statusnet_conversation_id
if (conversations.has(conversationId)) {
conversations.get(conversationId).set(status.id, status)
} else {
conversations.set(conversationId, new Map([[status.id, status]]))
}
// Work on quote
if (status.quote) {
status.quote = addStatus(status.quote)
}
return status
}
const processors = {
status: (status) => {
return addStatus(status)
},
edit: (status) => {
return addStatus(status)
},
retweet: (status) => {
// RetweetedStatuses are never shown immediately
if (status.retweeted_status) addStatus(status.retweeted_status)
return addStatus(status)
},
favorite: (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!this.favorites.has(favorite.id)) {
this.favorites.add(favorite.id)
const status = this.allStatuses.get(favorite.in_reply_to_status_id)
if (status) {
// This is our favorite, so the relevant bit.
if (favorite.user.id === useUsersStore().currentUser?.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
return null
},
follow: () => {
// NOOP, it is known status but we don't do anything about it for now
return null
},
default: (unknown) => {
console.warn('unknown status type', unknown)
return null
},
}
return statuses.map((status) => {
const type = status.type
const processor = processors[type] ?? processors.default
return processor(status)
})
},
mergeOrAdd(map, status, timestamp) {
const existing = map.get(status.id) ?? {}
const oldTimestamp = this.timestamps.get(existing)
const { user: unused0, ...old } = existing
const { user: statusUser, ...neu } = status
const [user] = useUsersStore().addNewUsers({
data: statusUser,
timestamp,
})
existing.user = user // reactive update in case we return old
// implicit: if oldTimestamp is undefined this will still be false
if (oldTimestamp > timestamp) return [existing, false] // not overwriting old data with new
const newStatus = {
...old,
...neu,
user,
}
map.set(newStatus.id, newStatus)
this.timestamps.set(newStatus, timestamp)
return [map.get(newStatus.id), true]
},
// Fetches
fetchStatus(id) {
return fetchStatus({ id }).then(({ data: status, timestamp }) =>
this.addNewStatuses({ statuses: [status], timestamp }),
)
},
fetchStatusSource(id) {
return fetchStatusSource({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
fetchStatusHistory(status) {
return fetchStatusHistory({ status }).then(({ data }) => data)
},
fetchEmojiReactionsBy(id) {
return fetchEmojiReactions({
id,
credentials: useOAuthStore().token,
}).then(({ data: emojiReactions }) => {
this.addEmojiReactionsBy({
id,
emojiReactions,
})
})
},
fetchFavs(id) {
return fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: favoritedByUsers }) =>
this.addFavs({
id,
favoritedByUsers,
}),
)
},
fetchRepeats(id) {
return fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: rebloggedByUsers }) =>
this.addRepeats({
id,
rebloggedByUsers,
}),
)
},
fetchFavsAndRepeats(id) {
return Promise.all([this.fetchFavs(id), this.fetchRepeats(id)])
},
// Updates
addRepeats({ id, rebloggedByUsers }) {
const currentUser = useUsersStore().currentUser
const newStatus = this.allStatuses.get(id)
newStatus.rebloggedBy = rebloggedByUsers.filter(Boolean)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(
- ({ id }) => currentUser.id === id,
+ ({ id }) => currentUser?.id === id,
)
},
addFavs({ id, favoritedByUsers }) {
const currentUser = useUsersStore().currentUser
const newStatus = this.allStatuses.get(id)
newStatus.favoritedBy = favoritedByUsers.filter(Boolean)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(
- ({ id }) => currentUser.id === id,
+ ({ id }) => currentUser?.id === id,
)
},
addEmojiReactionsBy({ id, emojiReactions }) {
const status = this.allStatuses.get(id)
status.emoji_reactions = emojiReactions
},
// Actions
requestInteract({ name, id, optimisticCall, apiCall, argument, value }) {
const oldValue = !value // Assumption
const apiArgs = (() => {
switch (name) {
case 'emoji':
return { emoji: argument }
case 'bookmark':
return { folder_id: argument }
default:
return {}
}
})()
// Optimistic
optimisticCall(id, value, argument)
return apiCall({
id,
...apiArgs,
credentials: useOAuthStore().token,
})
.then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
.catch((error) => {
optimisticCall(id, oldValue, argument)
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.interact_error',
messageArgs: [error],
timeout: 5000,
})
})
},
/// Favorite
favorite(id) {
return this.requestInteract({
name: 'favorite',
id,
apiCall: favorite,
optimisticCall: this.setFavorited,
value: true,
})
},
unfavorite(id) {
return this.requestInteract({
name: 'favorite',
id,
apiCall: unfavorite,
optimisticCall: this.setFavorited,
value: false,
})
},
setFavorited(id, value) {
const newStatus = this.allStatuses.get(id)
if (newStatus.favorited !== value) {
if (value) {
newStatus.fave_num++
} else {
newStatus.fave_num--
}
}
newStatus.favorited = value
},
/// Reprööt
retweet(id) {
return this.requestInteract({
name: 'retweet',
id,
apiCall: retweet,
optimisticCall: this.setRetweeted,
value: true,
})
},
unretweet(id) {
return this.requestInteract({
name: 'retweet',
id,
apiCall: unretweet,
optimisticCall: this.setRetweeted,
value: false,
})
},
setRetweeted(id, value) {
const newStatus = this.allStatuses.get(id)
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
// React
reactWithEmoji(id, emoji) {
return this.requestInteract({
name: 'emoji',
id,
apiCall: reactWithEmoji,
optimisticCall: this.setOwnReaction,
argument: emoji,
value: true,
})
},
unreactWithEmoji(id, emoji) {
return this.requestInteract({
name: 'emoji',
id,
apiCall: unreactWithEmoji,
optimisticCall: this.setOwnReaction,
argument: emoji,
value: false,
})
},
setOwnReaction(id, value, emoji) {
const currentUser = useUsersStore().currentUser
const status = this.allStatuses.get(id)
const reactionIndex = status.emoji_reactions.findIndex(
(react) => react.name === emoji,
)
const reactionPresent = reactionIndex >= 0
if (!value && !reactionPresent) return
const reaction = status.emoji_reactions[reactionIndex] || {
name: emoji,
count: 0,
accounts: [],
}
const count = value ? reaction.count + 1 : reaction.count - 1
const accounts = value
? [...reaction.accounts, currentUser]
: accounts.filter((acc) => acc.id !== currentUser.id)
const newReaction = {
...reaction,
count,
me: value,
accounts,
}
if (reactionPresent && count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else if (count === 0) {
status.emoji_reactions = status.emoji_reactions.filter(
(r) => r.name !== emoji,
)
} else {
status.emoji_reactions.push(newReaction)
}
},
/// Bookmark
bookmark(id, bookmark_folder_id) {
return this.requestInteract({
name: 'bookmark',
id,
apiCall: bookmarkStatus,
optimisticCall: this.setBookmarked,
argument: bookmark_folder_id,
value: false,
})
},
unbookmark(id) {
return this.requestInteract({
name: 'bookmark',
id,
apiCall: unbookmarkStatus,
optimisticCall: this.setBookmarked,
value: false,
})
},
setBookmarked(id, value, bookmark_folder_id) {
const status = this.allStatuses.get(id)
status.bookmarked = value
// When unbookmarking we don't specify folder so we wanna keep
// reference to the folder even when setting bookmarked to false
// the proper reference will be updated when api call resolves
if (bookmark_folder_id) {
status.bookmark_folder_id = value ? bookmark_folder_id : null
}
},
/// Mute
muteConversation(id) {
return this.requestInteract({
name: 'mute',
id,
apiCall: muteConversation,
optimisticCall: this.setMutedStatus,
value: false,
})
},
unmuteConversation(id) {
return this.requestInteract({
name: 'mute',
id,
apiCall: unmuteConversation,
optimisticCall: this.setMutedStatus,
value: false,
})
},
setMutedStatus(id, value) {
// Setting thread_muted flag on all other known statuses
// belonging to same conversation
const newStatus = this.allStatuses.get(id)
newStatus.thread_muted = value
if (newStatus.thread_muted !== undefined) {
this.conversations
.get(newStatus.statusnet_conversation_id)
.forEach((status) => {
status.thread_muted = value
})
}
},
/// Pin
pinStatus(id) {
return this.requestInteract({
name: 'pin',
id,
apiCall: pinOwnStatus,
optimisticCall: () => {
/* no-op */
},
value: true,
})
},
unpinStatus(id) {
return this.requestInteract({
name: 'pin',
id,
apiCall: unpinOwnStatus,
optimisticCall: () => {
/* no-op */
},
value: false,
})
},
/// Delete
deleteStatus(id) {
deleteStatus({
id,
credentials: useOAuthStore().token,
})
.then(() => {
this.setDeleted(id)
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.delete_error',
messageArgs: [e.message],
timeout: 5000,
})
})
},
setDeleted(id) {
const newStatus = this.allStatuses.get(id)
if (newStatus) newStatus.deleted = true
},
setManyDeleted(condition) {
this.allStatuses.values().forEach((status) => {
if (condition(status)) {
status.deleted = true
}
})
},
// Search
search({ q, resolve, limit, offset, following, type }) {
return search2({
q,
resolve,
limit,
offset,
following,
type,
credentials: useOAuthStore().token,
}).then((result) => {
const { data, ...rest } = result
useUsersStore().addNewUsers({
...rest,
data: data.accounts,
})
useUsersStore().addNewUsers({
...rest,
data: data.statuses.map((s) => s.user).filter(Boolean),
})
this.addNewStatuses({
statuses: data.statuses,
})
data.statuses = data.statuses.map((s) => this.allStatuses.get(s.id))
return data
})
},
setVirtualHeight({ statusId, height }) {
this.allStatuses.get(statusId).virtualHeight = height
},
updateStatusWithPoll({ id, poll }) {
const status = this.allStatuses.get(id)
status.poll = poll
},
},
})
diff --git a/src/stores/streaming.js b/src/stores/streaming.js
index 7038a709c9..3ae00fb320 100644
--- a/src/stores/streaming.js
+++ b/src/stores/streaming.js
@@ -1,237 +1,239 @@
import { defineStore } from 'pinia'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
getMastodonSocketURI,
ProcessedWS,
WSConnectionStatus,
} from 'src/api/websocket.js'
const ARGUMENT_MAP = {
tag: 'tag',
list: 'list',
}
export const TIMELINE_STREAM_MAP = {
friends: 'user',
public: 'public',
tag: 'hashtag',
list: 'list',
dms: 'direct',
}
const retryTimeout = (multiplier) => 1000 * multiplier
export class StreamStateEvent extends Event {
original
constructor(name, original) {
super(name)
this.original = original
}
}
export class StreamErrorEvent extends Event {
error
constructor(error) {
super('error', error)
this.error = error
}
}
export class StreamMessageEvent extends Event {
data
stream
timestamp
constructor(name, stream, data) {
super(name)
this.data = data
this.stream = stream
this.timestamp = Date.now()
}
}
export const useStreamingStore = defineStore('streaming', {
state: () => ({
socket: null,
error: null,
state: null,
retryMultiplier: 1,
subscribers: new Set(),
subscriptions: new Map(),
globalSubscriptions: new Set(),
}),
actions: {
addSubscriber(subscriber) {
const { stream, et } = subscriber
if (stream) {
if (!this.subscriptions.has(stream.name)) {
this.subscriptions.set(stream.name, new Map())
}
const streamSubs = this.subscriptions.get(stream.name)
if (streamSubs.has(stream.argument)) {
throw new Error('Subscription already exists!')
}
streamSubs.set(stream.argument, subscriber)
} else {
this.globalSubscriptions.add(subscriber)
}
this.subscribers.add(subscriber)
if (this.state === WSConnectionStatus.JOINED) {
if (stream) {
this.socket.subscribe(...this.getSubArgs(stream))
}
et.dispatchEvent(new StreamStateEvent('open'))
}
},
removeSubscriber(subscriber) {
const { stream } = subscriber
this.subscribers.delete(subscriber)
- this.subscriptions.get(stream.name).delete(stream.argument)
+ if (stream) {
+ this.subscriptions.get(stream.name).delete(stream.argument)
+ }
if (this.state === WSConnectionStatus.JOINED) {
this.socket.unsubscribe(...this.getSubArgs(stream))
}
},
initSocket(initial) {
this.state = initial
? WSConnectionStatus.STARTING_INITIAL
: WSConnectionStatus.STARTING
const credentials = useOAuthStore().token
const url = getMastodonSocketURI({ credentials })
this.socket = ProcessedWS({
url,
id: 'Unified',
credentials,
})
this.socket.addEventListener('pleroma:authenticated', this.onAuth)
this.socket.addEventListener('open', this.onOpen)
this.socket.addEventListener('close', this.onClose)
this.socket.addEventListener('message', this.onMessage)
this.socket.addEventListener('error', this.onError)
},
stopSocket() {
this.socket.close()
},
getSubArgs(stream) {
const argumentKey = ARGUMENT_MAP[stream.name]
const args = argumentKey
? {
[argumentKey]: stream.argument,
}
: null
return [stream.name, args]
},
onAuth() {
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamStateEvent('authenticated'))
if (stream) {
this.socket.subscribe(...this.getSubArgs(stream))
}
})
this.state = WSConnectionStatus.JOINED
},
onOpen() {
this.retryMultiplier = 1
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamStateEvent('open'))
})
},
onMessage({ data: message }) {
if (!message) return // pings
const { event: eventName, stream: eventStream, ...data } = message
const [streamName, streamArgument] = eventStream ?? []
const subscriber = this.subscriptions.get(streamName)?.get(streamArgument)
const totalSubs = [
...this.globalSubscriptions.values(),
subscriber,
].filter(Boolean)
const eventData = (() => {
switch (eventName) {
case 'status.update':
case 'update':
return [data.status]
case 'notification':
return [data.notification]
case 'delete':
return [data.id]
default:
console.log('UNKNOWN', eventName, eventStream, data)
return data
}
})()
const event = new StreamMessageEvent(
eventName,
{ name: streamName, argument: streamArgument },
eventData,
)
totalSubs.forEach(({ stream, et }) => {
et.dispatchEvent(event)
})
},
onError({ data: error }) {
this.subscribers.forEach(({ stream, et }) => {
et.dispatchEvent(new StreamErrorEvent(error))
})
console.error('Error in MastoAPI websocket:', error)
},
onClose({ data: closeEvent }) {
const ignoreCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent
if (ignoreCodes.has(code)) {
console.debug(
`Not restarting socket becasue of closure code ${code} is in ignore list`,
)
this.state = WSConnectionStatus.CLOSED
this.retryMultiplier = 1
this.subscribers.forEach(({ et }) => {
et.dispatchEvent(new StreamStateEvent('close', closeEvent))
})
} else {
console.warn(
`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`,
)
setTimeout(() => {
this.initSocket()
}, retryTimeout(this.retryMultiplier))
this.retryMultiplier += 1
if (this.state !== WSConnectionStatus.ERROR) {
this.subscribers.forEach(({ et }) => {
et.dispatchEvent(new StreamStateEvent('close', closeEvent))
})
}
this.state = WSConnectionStatus.ERROR
}
},
},
})
diff --git a/src/stores/users.js b/src/stores/users.js
index 1702e4f80a..6a2ed8a8a2 100644
--- a/src/stores/users.js
+++ b/src/stores/users.js
@@ -1,783 +1,782 @@
import Cookies from 'js-cookie'
import { last, map } from 'lodash'
import { defineStore } from 'pinia'
import {
registerPushNotifications,
unregisterPushNotifications,
} from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
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,
unmuteDomain,
unmuteUser,
} from 'src/api/user.js'
const getNotificationPermission = async () => {
const Notification = window.Notification
if (!Notification) return null
if (Notification.permission === 'default')
return Notification.requestPermission()
return Notification.permission
}
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(),
}),
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: {
tagUser({ user: { id }, tag }) {
const user = this.users.get(id)
user.tags.add(tag)
},
untagUser({ user: { id }, tag }) {
const user = this.users.get(id)
user.tags.delete(tag)
},
updateRight({ user: { id }, right, value }) {
const user = this.users.get(id)
const newRights = user.rights
newRights[right] = value
user.rights = newRights
},
async updateUserAdminData({ user }) {
const localUser = await this.fetchUserIfMissing({ id: user.id })
localUser.adminData = user
localUser.deactivated = !user.is_active
localUser.tags = new Set(user.tags)
},
setCurrentUser(user) {
this.lastLoginName = user.screen_name
this.currentUser = user
},
clearCurrentUser() {
this.currentUser = null
this.lastLoginName = null
},
saveFriendIds(id, friendIds) {
const user = this.users.get(id)
const list = this.relationshipsLists.friends.get(user)
friendIds.forEach((id) => list.add(id))
},
saveFollowerIds(id, followerIds) {
const user = this.users.get(id)
const list = this.relationshipsLists.followers.get(user)
followerIds.forEach((id) => list.add(id))
},
// Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile.
clearFriends(userId) {
const user = this.users.get(userId)
if (user) {
user.friendIds = new Set()
}
},
clearFollowers(userId) {
const user = this.users.get(userId)
if (user) {
user.followerIds = new Set()
}
},
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 relationship = this.updateUserRelationships({
timestamp,
data: { id: user.id, ...(user.relationship ?? {}) },
})[0]
// 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
// Initializing reactivity
if (!this.users.has(user.id)) this.users.set(user.id, existing)
const reactive = this.users.get(user.id)
// Relying on object reactivity to avoid mutating the Map
reactive.relationship = relationship
Object.entries(newUser).forEach(([k, v]) => {
reactive[k] = v
})
// Updating the timestamp
this.timestamps.set(reactive, timestamp)
// Avoiding excessive Map mutation
if (!this.users.has(user.id)) {
this.usersByName.set(user.screen_name.toLowerCase(), reactive)
this.usersByURL.set(user.url.toLowerCase(), reactive)
}
- if (user.id === this.currentUser.id) {
+ 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) 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)
}
// Updating user property if there is such a user
if (this.users.has(id)) {
this.users.get(id).relationship = reactive
}
return reactive
})
},
updateUserInLists({ id, inLists }) {
this.users.get(id).inLists = inLists
},
saveBlockIds(blockIds) {
this.currentUser.blockIds = blockIds
},
addBlockId(blockId) {
this.currentUser.blockIds.add(blockId)
},
setBlockIdsMaxId(blockIdsMaxId) {
this.currentUser.blockIdsMaxId = blockIdsMaxId
},
saveMuteIds(muteIds) {
this.currentUser.muteIds = muteIds
},
setMuteIdsMaxId(muteIdsMaxId) {
this.currentUser.muteIdsMaxId = muteIdsMaxId
},
addMuteId(muteId) {
this.currentUser.muteIds.add(muteId)
},
saveDomainMutes(domainMutes) {
this.currentUser.domainMutes = domainMutes
},
addDomainMute(domain) {
if (this.currentUser.domainMutes.includes(domain)) {
this.currentUser.domainMutes.push(domain)
}
},
removeDomainMute(domain) {
const index = this.currentUser.domainMutes.indexOf(domain)
if (index !== -1) {
this.currentUser.domainMutes.splice(index, 1)
}
},
setPinnedToUser(status) {
const user = this.users.get(status.user.id)
user.pinnedStatusIds = user.pinnedStatusIds || []
const index = user.pinnedStatusIds.indexOf(status.id)
if (status.pinned && index === -1) {
user.pinnedStatusIds.push(status.id)
} else if (!status.pinned && index !== -1) {
user.pinnedStatusIds.splice(index, 1)
}
},
setUserForStatus(status) {
status.user = this.users.get(status.user.id)
},
setUserForNotification(notification) {
if (notification.type !== 'follow') {
notification.action.user = this.users.get(notification.action.user.id)
}
notification.from_profile = this.users.get(notification.from_profile.id)
},
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)
// not found => fetch
if (!user) {
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?.data) {
const { id, screen_name } = result.data
// Save promise for future use
this.fetchesIds.set(id, promise)
this.fetchesNames.set(screen_name, promise)
this.addNewUsers(result)
return this.users.get(id)
} else {
return null
}
} else {
return user
}
},
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) {
if (this.currentUser) {
fetchUserRelationship({
id,
credentials: useOAuthStore().token,
}).then((result) => this.updateUserRelationships(result))
}
},
fetchUserInLists(id) {
if (this.currentUser) {
fetchUserInLists({
id,
credentials: useOAuthStore().token,
}).then(({ data: inLists }) => this.updateUserInLists({ id, inLists }))
}
},
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.saveBlockIds(new Set(blocks.map(({ id }) => id)))
} else {
blocks.forEach(({ id }) => this.addBlockId(id))
}
if (blocks.length) {
this.setBlockIdsMaxId(last(blocks).id)
}
this.addNewUsers(result)
return blocks
})
},
blockUser(id, expiresIn = 0) {
const store = window.vuex
const predictedRelationship = this.relationships[id] || { id }
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
this.addBlockId(id)
return blockUser({ id, expiresIn }).then((result) => {
this.updateUserRelationships(result)
this.addBlockId(id)
store.commit('removeStatus', { timeline: 'friends', userId: id })
store.commit('removeStatus', { timeline: 'public', userId: id })
store.commit('removeStatus', {
timeline: 'publicAndExternal',
userId: id,
})
})
},
unblockUser(id) {
return unblockUser({ id }).then((data) =>
this.updateUserRelationships(data),
)
},
removeUserFromFollowers(id) {
return removeUserFromFollowers({ id }).then((data) =>
this.updateUserRelationships(data),
)
},
blockUsers(data = []) {
return Promise.all(data.map((d) => this.blockUser(d)))
},
unblockUsers(data = []) {
return Promise.all(data.map((d) => unblockUser(d)))
},
editUserNote({ id, comment }) {
return editUserNote({ id, comment }).then(({ data }) =>
this.updateUserRelationships(data),
)
},
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.saveMuteIds(new Set(mutes.map(({ id }) => id)))
} else {
mutes.forEach(({ id }) => this.addMuteId(id))
}
if (mutes.length) {
this.setMuteIdsMaxId(last(mutes).id)
}
this.addNewUsers(result)
return mutes
})
},
muteUser(id, expiresIn = 0) {
const predictedRelationship = this.relationships[id] || { id }
predictedRelationship.muting = true
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
this.addMuteId(id)
return muteUser({
id,
expiresIn,
credentials: useOAuthStore().token,
}).then(({ data }) => {
this.updateUserRelationships(data)
this.addMuteId(id)
})
},
unmuteUser(id) {
const predictedRelationship = this.relationships[id] || { id }
predictedRelationship.muting = false
this.updateUserRelationships({
optimism: true,
data: [predictedRelationship],
})
return unmuteUser({ id }).then(({ data }) =>
this.updateUserRelationships(data),
)
},
hideReblogs(id) {
return followUser({
id,
reblogs: false,
credentials: useOAuthStore().token,
}).then(({ data }) => this.updateUserRelationships(data))
},
showReblogs(id) {
return followUser({
id,
reblogs: true,
credentials: useOAuthStore().token,
}).then(({ data }) => this.updateUserRelationships(data))
},
muteUsers(data = []) {
return Promise.all(data.map((d) => this.muteUser(d)))
},
unmuteUsers(ids = []) {
return Promise.all(ids.map((d) => this.unmuteUser(d)))
},
fetchDomainMutes() {
return fetchDomainMutes({
credentials: useOAuthStore().token,
}).then(({ data: domainMutes }) => {
this.saveDomainMutes(domainMutes)
return domainMutes
})
},
muteDomain(domain) {
return muteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => this.addDomainMute(domain))
},
unmuteDomain(domain) {
return unmuteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => this.removeDomainMute(domain))
},
muteDomains(domains = []) {
return Promise.all(domains.map((domain) => this.muteDomain(domain)))
},
unmuteDomains(domain = []) {
return Promise.all(domain.map((domain) => this.unmuteDomain(domain)))
},
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) => {
this.addNewUsers(result)
this.saveFriendIds(id, map(result.data, '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) => {
this.addNewUsers(result)
this.saveFollowerIds(id, map(result.data, 'id'))
return result.data
})
},
subscribeUser(id) {
return followUser({
id,
notify: true,
credentials: useOAuthStore().token,
}).then(({ data }) => this.updateUserRelationships(data))
},
unsubscribeUser(id) {
return followUser({
id,
notify: false,
credentials: useOAuthStore().token,
}).then(({ data }) => this.updateUserRelationships(data))
},
registerPushNotifications() {
const token = this.currentUser.credentials
const vapidPublicKey = useInstanceStore().vapidPublicKey
const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications
const notificationVisibility =
useMergedConfigStore().mergedConfig.notificationVisibility
registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
)
},
unregisterPushNotifications() {
const token = this.currentUser.credentials
unregisterPushNotifications(token)
},
searchUsers({ query }) {
return searchUsers({
query,
credentials: useOAuthStore().token,
}).then(({ data: users }) => {
this.addNewUsers(users)
return users
})
},
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.clearCurrentUser()
- store.dispatch('stopFetchingNotifications')
+ useNotificationsStore().deactivate()
useListsStore().stopFetching()
useBookmarkFoldersStore().stopFetching()
store.dispatch('stopFetchingFollowRequests')
- store.commit('clearNotifications')
useTimelinesStore().deactivateAll()
useStatusesStore().resetStatuses()
useNotificationsStore().clearNotifications()
useChatsStore().resetChats()
oauth.clearToken()
Cookies.remove('__Host-pleroma_key', { path: '/' })
useInterfaceStore().setLastTimeline('public-timeline')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
})
},
loginUser(accessToken) {
return new Promise((resolve, reject) => {
const store = window.vuex
const dispatch = store.dispatch
this.loggingIn = true
verifyCredentials({
credentials: useOAuthStore().token,
})
.then(({ data: user, ...rest }) => {
// user.credentials = userCredentials
user.credentials = accessToken
user.blockIds = new Set()
user.muteIds = new Set()
user.domainMutes = new Set()
this.setCurrentUser(user)
useSyncConfigStore()
.initSyncConfig(user)
.then(() => {
useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
})
useUserHighlightStore().initUserHighlight(user)
this.addNewUsers({ data: user, ...rest })
useEmojiStore().fetchEmoji()
getNotificationPermission().then((permission) =>
useInterfaceStore().setNotificationPermission(permission),
)
// 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().startFetchingChats()
}
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().startFetchingAnnouncements()
this.fetchMutes()
dispatch('loadDrafts')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
// Fetch our friends
fetchFriends({ id: user.id }).then((friends) =>
this.addNewUsers(friends),
)
this.loggingIn = false
resolve()
})
.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()
}
this.loggingIn = false
if (error.tatusCode === 401) {
throw new Error('Wrong username or password', error)
} else {
throw new Error('An error occurred, please try again', error)
}
})
})
},
},
persist: {
paths: ['lastLoginName'],
},
})
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Oct 30, 9:41 PM (16 h, 28 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1800573
Default Alt Text
(100 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline