Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
110 KB
Referenced Files
None
Subscribers
None
diff --git a/src/api/websocket.js b/src/api/websocket.js
index ae2e5358fe..863e609f5a 100644
--- a/src/api/websocket.js
+++ b/src/api/websocket.js
@@ -1,174 +1,174 @@
import { paramsString } from './helpers.js'
import {
parseChat,
parseNotification,
parseStatus,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const MASTODON_STREAMING = ({ accessToken, stream }) =>
`/api/v1/streaming${paramsString({ accessToken, stream })}`
export const getMastodonSocketURI = ({ credentials, stream }) => {
return MASTODON_STREAMING({ accessToken: credentials, stream })
}
const MASTODON_STREAMING_EVENTS = new Set([
'update',
'notification',
'delete',
'filters_changed',
'status.update',
])
const PLEROMA_STREAMING_EVENTS = new Set([
'pleroma:chat_update',
'pleroma:respond',
])
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({
url,
preprocessor = handleMastoWS,
id = 'Unknown',
credentials,
}) => {
const eventTarget = new EventTarget()
const socket = new WebSocket(url)
if (!socket) throw new Error(`Failed to create socket ${id}`)
const proxy = (original, eventName, processor = (a) => a) => {
original.addEventListener(eventName, (eventData) => {
eventTarget.dispatchEvent(
new CustomEvent(eventName, { detail: processor(eventData) }),
)
})
}
socket.addEventListener('open', (wsEvent) => {
console.debug(`[WS][${id}] Socket connected`, wsEvent)
if (credentials) {
socket.send(
JSON.stringify({
type: 'pleroma:authenticate',
token: credentials,
}),
)
}
})
socket.addEventListener('error', (wsEvent) => {
console.debug(`[WS][${id}] Socket errored`, wsEvent)
})
socket.addEventListener('close', (wsEvent) => {
console.debug(
`[WS][${id}] Socket disconnected with code ${wsEvent.code}`,
wsEvent,
)
})
// Commented code reason: very spammy, uncomment to enable message debug logging
/*
socket.addEventListener('message', (wsEvent) => {
console.debug(
`[WS][${id}] Message received`,
wsEvent
)
})
/**/
const onAuthenticated = () => {
eventTarget.dispatchEvent(new CustomEvent('pleroma:authenticated'))
}
proxy(socket, 'open')
proxy(socket, 'close')
proxy(socket, 'message', (event) => preprocessor(event, { onAuthenticated }))
proxy(socket, 'error')
// 1000 = Normal Closure
eventTarget.close = () => {
socket.close(1000, 'Shutting down socket')
}
eventTarget.getState = () => socket.readyState
eventTarget.subscribe = (stream, args = {}) => {
console.debug(`[WS][${id}] Subscribing to stream ${stream} with args`, args)
socket.send(
JSON.stringify({
type: 'subscribe',
stream,
...args,
}),
)
}
eventTarget.unsubscribe = (stream, args = {}) => {
console.debug(
`[WS][${id}] Unsubscribing from stream ${stream} with args`,
args,
)
socket.send(
JSON.stringify({
type: 'unsubscribe',
stream,
...args,
}),
)
}
return eventTarget
}
export const handleMastoWS = (
wsEvent,
{
onAuthenticated = () => {
/* no-op */
},
} = {},
) => {
const { data } = wsEvent
if (!data) return
const parsedEvent = JSON.parse(data)
- const { event, payload } = parsedEvent
+ const { event, stream, payload } = parsedEvent
if (
MASTODON_STREAMING_EVENTS.has(event) ||
PLEROMA_STREAMING_EVENTS.has(event)
) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') {
- return { event, id: payload }
+ return { event, stream, id: payload }
}
const data = payload ? JSON.parse(payload) : null
if (event === 'pleroma:respond') {
if (data.type === 'pleroma:authenticate') {
if (data.result === 'success') {
console.debug('[WS] Successfully authenticated')
onAuthenticated()
} else if (data.error === 'already_authenticated') {
onAuthenticated()
} else {
console.error('[WS] Unable to authenticate:', data.error)
wsEvent.target.close()
}
}
return null
} else if (event === 'update') {
- return { event, status: parseStatus(data) }
+ return { event, stream, status: parseStatus(data) }
} else if (event === 'status.update') {
- return { event, status: parseStatus(data) }
+ return { event, stream, status: parseStatus(data) }
} else if (event === 'notification') {
- return { event, notification: parseNotification(data) }
+ return { event, stream, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
- return { event, chatUpdate: parseChat(data) }
+ return { event, stream, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
return null
}
}
export const WSConnectionStatus = Object.freeze({
JOINED: 1,
CLOSED: 2,
ERROR: 3,
DISABLED: 4,
STARTING: 5,
STARTING_INITIAL: 6,
})
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 8f8335c9c7..4ea6f39e6f 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -1,310 +1,310 @@
import AuthForm from 'src/components/auth_form/auth_form.js'
import ConversationPage from 'src/components/conversation-page/conversation-page.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import RemoteUserResolver from 'src/components/remote_user_resolver/remote_user_resolver.vue'
import Timeline from 'src/components/timeline/timeline.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useUsersStore } from 'src/stores/users.js'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
if (useUsersStore().currentUser) {
next()
} else {
next(
useInstanceStore().instanceIdentity.redirectRootNoLogin || '/main/all',
)
}
}
let routes = [
{
name: 'root',
path: '/',
redirect: () => {
return (
(useUsersStore().currentUser
? useInstanceStore().instanceIdentity.redirectRootLogin
: useInstanceStore().instanceIdentity.redirectRootNoLogin) ||
'/main/all'
)
},
},
{
name: 'public-external-timeline',
path: '/main/all',
component: Timeline,
props: () => ({
timelineRef: { name: 'publicAndExternal' },
}),
},
{
name: 'public-timeline',
path: '/main/public',
component: Timeline,
props: () => ({
timelineRef: { name: 'public' },
}),
},
{
name: 'friends',
path: '/main/friends',
component: Timeline,
beforeEnter: validateAuthenticatedRoute,
props: () => ({
timelineRef: { name: 'friends' },
}),
},
{
name: 'tag-timeline',
path: '/tag/:id',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'tag', argument: route.params.id },
}),
},
{
name: 'bookmarks',
path: '/bookmarks',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'bookmarks', argument: null },
}),
},
{
name: 'bubble',
path: '/bubble',
component: Timeline,
props: () => ({
timelineRef: { name: 'bubble' },
}),
},
{
name: 'conversation',
path: '/notice/:id',
component: ConversationPage,
meta: { dontScroll: true },
},
{
name: 'conversation2',
path: '/conversation/:statusId',
component: () => import('src/components/chat_view/chat_view.vue'),
props: true,
meta: { dontScroll: true },
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'quotes',
path: '/notice/:id/quotes',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'quotes', argument: route.params.id },
}),
},
{
name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'external-user-profile',
path: '/users/$:id',
component: () => import('src/components/user_profile/user_profile.vue'),
},
{
name: 'user-profile-admin-view',
path: '/users/$:id/admin_view',
component: () =>
import('src/components/user_profile/user_profile_admin_view.vue'),
},
{
name: 'interactions',
path: '/users/:username/interactions',
component: () => import('src/components/interactions/interactions.vue'),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'dms',
path: '/users/:username/dms',
component: Timeline,
beforeEnter: validateAuthenticatedRoute,
props: () => ({
timelineRef: { name: 'dms' },
}),
},
{
name: 'registration',
path: '/registration',
component: () => import('src/components/registration/registration.vue'),
},
{
name: 'password-reset',
path: '/password-reset',
component: () =>
import('src/components/password_reset/password_reset.vue'),
props: true,
},
{
name: 'registration-token',
path: '/registration/:token',
component: () => import('src/components/registration/registration.vue'),
},
{
name: 'friend-requests',
path: '/friend-requests',
component: () =>
import('src/components/follow_requests/follow_requests.vue'),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'notifications',
path: '/:username/notifications',
component: () => import('src/components/notifications/notifications.vue'),
props: () => ({ disableTeleport: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'login',
path: '/login',
component: AuthForm,
},
{
name: 'shout-panel',
path: '/shout-panel',
component: () => import('src/components/shout_panel/shout_panel.vue'),
props: () => ({ floating: false }),
},
{
name: 'oauth-callback',
path: '/oauth-callback',
component: () =>
import('src/components/oauth_callback/oauth_callback.vue'),
props: (route) => ({ code: route.query.code }),
},
{
name: 'search',
path: '/search',
component: () => import('src/components/search/search.vue'),
props: (route) => ({ query: route.query.query }),
},
{
name: 'who-to-follow',
path: '/who-to-follow',
component: () => import('src/components/who_to_follow/who_to_follow.vue'),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'about',
path: '/about',
component: () => import('src/components/about/about.vue'),
},
{
name: 'announcements',
path: '/announcements',
component: () =>
import('src/components/announcements_page/announcements_page.vue'),
},
{
name: 'drafts',
path: '/drafts',
component: () => import('src/components/drafts/drafts.vue'),
},
{
name: 'user-profile',
path: '/users/:name',
component: () => import('src/components/user_profile/user_profile.vue'),
},
{
name: 'legacy-user-profile',
path: '/:name',
component: () => import('src/components/user_profile/user_profile.vue'),
},
{
name: 'lists',
path: '/lists',
component: () => import('src/components/lists/lists.vue'),
},
{
name: 'lists-timeline',
path: '/lists/:id',
component: Timeline,
props: (route) => ({
- timelineRef: { name: 'lists', argument: route.params.id },
+ timelineRef: { name: 'list', argument: route.params.id },
}),
},
{
name: 'lists-edit',
path: '/lists/:id/edit',
component: () => import('src/components/lists_edit/lists_edit.vue'),
},
{
name: 'lists-new',
path: '/lists/new',
component: () => import('src/components/lists_edit/lists_edit.vue'),
},
{
name: 'edit-navigation',
path: '/nav-edit',
component: NavPanel,
props: () => ({ forceExpand: true, forceEditMode: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'bookmark-folders',
path: '/bookmark_folders',
component: () =>
import('src/components/bookmark_folders/bookmark_folders.vue'),
},
{
name: 'bookmark-folder-new',
path: '/bookmarks/new-folder',
component: () =>
import('src/components/bookmark_folder_edit/bookmark_folder_edit.vue'),
},
{
name: 'bookmark-folder',
path: '/bookmarks/:id',
component: Timeline,
props: (route) => ({
timelineRef: { name: 'bookmarks', argument: route.params.id },
}),
},
{
name: 'bookmark-folder-edit',
path: '/bookmarks/:id/edit',
component: () =>
import('src/components/bookmark_folder_edit/bookmark_folder_edit.vue'),
},
]
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
routes = routes.concat([
{
name: 'chat',
path: '/users/:username/chats/:chatUserId',
component: () => import('src/components/chat_view/chat_view.vue'),
meta: { dontScroll: false },
props: true,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'chats',
path: '/users/:username/chats',
component: () => import('src/components/chat_list/chat_list.vue'),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
])
}
return routes
}
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index a9433111d3..a71097782e 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,298 +1,300 @@
import { debounce, keyBy, 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 { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useUsersStore } from 'src/stores/users.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.visibleStatusesIds.keys()]
.map((id) => this.timeline.statuses.get(id))
- .filter(({ pinned }) => this.skipPinned ? !pinned : true)
+ .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.visibleStatusesIds.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.visibleStatusesIds.keys()].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) {
- // TODO this might not be necessary if we optimize mergeOrAdd
const sameName = newTimeline?.name === oldTimeline?.name
const sameArgument = newTimeline?.argument === oldTimeline?.argument
if (sameName && sameArgument) return
- if (oldTimeline && oldTimeline.name !== 'friends') {
- useTimelinesStore().clearTimeline(oldTimeline.name)
+ if (oldTimeline) {
+ useTimelinesStore().deactivate(oldTimeline.name)
+ }
+ if (newTimeline) {
+ useTimelinesStore().activate(newTimeline.name, newTimeline.argument)
}
- useTimelinesStore().startFetchingTimeline(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
}
})
},
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.$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/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index db96926d9a..109c0bbbce 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -1,180 +1,183 @@
import { get } from 'lodash'
import FollowCard from 'src/components/follow_card/follow_card.vue'
import List from 'src/components/list/list.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Timeline from 'src/components/timeline/timeline.vue'
import UserCard from 'src/components/user_card/user_card.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useUsersStore } from 'src/stores/users.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const defaultTabKey = 'statuses'
const UserProfile = {
data() {
return {
error: false,
userId: null,
tab: defaultTabKey,
footerRef: null,
}
},
created() {
const routeParams = this.$route.params
this.load({ name: routeParams.name, id: routeParams.id })
this.tab = get(this.$route, 'query.tab', defaultTabKey)
useInterfaceStore().setForeignProfileBackground(this.user?.background_image)
},
updated() {
useInterfaceStore().setForeignProfileBackground(this.user?.background_image)
},
unmounted() {
useInterfaceStore().setForeignProfileBackground(null)
this.$store.dispatch('clearFollowers', this.userId)
this.$store.dispatch('clearFriends', this.userId)
},
computed: {
favorites() {
return useTimelinesStore().favorites
},
media() {
return useTimelinesStore().media
},
isUs() {
return (
this.userId &&
useUsersStore().currentUser.id &&
this.userId === useUsersStore().currentUser.id
)
},
user() {
return useUsersStore().findUser(this.userId)
},
isExternal() {
return this.$route.name === 'external-user-profile'
},
followsTabVisible() {
return this.isUs || !this.user.hide_follows
},
followersTabVisible() {
return this.isUs || !this.user.hide_followers
},
favoritesTabVisible() {
return (
this.isUs ||
(useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable &&
!this.user.hide_favorites)
)
},
compactProfiles() {
return useMergedConfigStore().mergedConfig.compactProfiles
},
friends() {
- return [...useUsersStore().relationshipsLists.friends.get(this.user).keys()]
- .map((id) => useUsersStore().findUser(id))
+ return [
+ ...useUsersStore().relationshipsLists.friends.get(this.user).keys(),
+ ].map((id) => useUsersStore().findUser(id))
},
followers() {
- return [...useUsersStore().relationshipsLists.followers.get(this.user).keys()]
- .map((id) => useUsersStore().findUser(id))
+ return [
+ ...useUsersStore().relationshipsLists.followers.get(this.user).keys(),
+ ].map((id) => useUsersStore().findUser(id))
},
},
methods: {
setFooterRef(el) {
this.footerRef = el
},
fetchUsers(group) {
return () =>
- useUsersStore()['fetch' + group](this.userId)
+ useUsersStore()
+ ['fetch' + group](this.userId)
.then((result) => ({ items: result }))
},
load(userNameOrId) {
const loadById = (userId) => {
console.log('LOAD', userId)
this.userId = userId
}
// Reset view
this.userId = null
this.error = false
const maybeId = userNameOrId.id
const maybeName = userNameOrId.name
// Check if user data is already loaded in store
const user = maybeId
? useUsersStore().findUser(maybeId)
: useUsersStore().findUserByName(maybeName)
if (user) {
loadById(user.id)
} else {
const promise = maybeId
? useUsersStore().fetchUser(maybeId)
: useUsersStore().fetchUserByName(maybeName)
promise
.then(({ id }) => loadById(id))
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
if (errorMessage === 'No user with such user_id') {
// Known error
this.error = this.$t('user_profile.profile_does_not_exist')
} else if (errorMessage) {
this.error = errorMessage
} else {
this.error = this.$t('user_profile.profile_loading_error')
}
})
}
},
switchUser(userNameOrId) {
console.log('USER SWITCH')
this.load(userNameOrId)
},
onTabSwitch(tab) {
this.tab = tab
this.$router.replace({ query: { tab } })
},
linkClicked({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
},
watch: {
'$route.params.id': function (newVal) {
if (newVal) {
this.switchUser({ id: newVal })
}
},
'$route.params.name': function (newVal) {
if (newVal) {
this.switchUser({ name: newVal })
}
},
'$route.query': function (newVal) {
this.tab = newVal.tab || defaultTabKey
},
},
components: {
UserCard,
Timeline,
List,
FollowCard,
TabSwitcher,
},
}
export default UserProfile
diff --git a/src/modules/api.js b/src/modules/api.js
index ca7bba29c3..3fddb819f4 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,299 +1,149 @@
import { Socket } from 'phoenix'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useShoutStore } from 'src/stores/shout.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import {
getMastodonSocketURI,
ProcessedWS,
WSConnectionStatus,
} from 'src/api/websocket.js'
import followRequestFetcher from 'src/services/follow_request_fetcher/follow_request_fetcher.service'
import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js'
const retryTimeout = (multiplier) => 1000 * multiplier
const api = {
state: {
retryMultiplier: 1,
fetchers: {},
socket: null,
mastoUserSocket: null,
mastoUserSocketStatus: null,
followRequests: [],
},
getters: {
followRequestCount: (state) => state.followRequests.length,
},
mutations: {
addFetcher(state, { fetcherName, fetcher }) {
state.fetchers[fetcherName] = fetcher
},
removeFetcher(state, { fetcherName }) {
state.fetchers[fetcherName].stop()
delete state.fetchers[fetcherName]
},
setWsToken(state, token) {
state.wsToken = token
},
setSocket(state, socket) {
state.socket = socket
},
setFollowRequests(state, value) {
state.followRequests = value
},
setMastoUserSocketStatus(state, value) {
state.mastoUserSocketStatus = value
},
incrementRetryMultiplier(state) {
state.retryMultiplier = Math.max(++state.retryMultiplier, 3)
},
resetRetryMultiplier(state) {
state.retryMultiplier = 1
},
},
actions: {
/**
* Global MastoAPI socket control, in future should disable ALL sockets/(re)start relevant sockets
*
* @param {Boolean} [initial] - whether this enabling happened at boot time or not
*/
enableMastoSockets(store, initial) {
const { state, dispatch, commit } = store
// Do not initialize unless nonexistent or closed
if (
state.mastoUserSocket &&
![WebSocket.CLOSED, WebSocket.CLOSING].includes(
state.mastoUserSocket.getState(),
)
) {
return
}
if (initial) {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING_INITIAL)
} else {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING)
}
return dispatch('startMastoUserSocket')
},
disableMastoSockets(store) {
const { state, dispatch, commit } = store
if (!state.mastoUserSocket) return
commit('setMastoUserSocketStatus', WSConnectionStatus.DISABLED)
return dispatch('stopMastoUserSocket')
},
- // MastoAPI 'User' sockets
- startMastoUserSocket(store) {
- return new Promise((resolve, reject) => {
- try {
- const { state, commit, dispatch, rootState } = store
- const timelineData = rootState.statuses.timelines.friends
-
- const credentials = useOAuthStore().token
- const url = getMastodonSocketURI({ credentials })
-
- state.mastoUserSocket = ProcessedWS({
- url,
- id: 'Unified',
- credentials,
- })
-
- state.mastoUserSocket.addEventListener(
- 'pleroma:authenticated',
- () => {
- state.mastoUserSocket.subscribe('user')
- },
- )
- state.mastoUserSocket.addEventListener(
- 'message',
- ({ detail: message }) => {
- if (!message) return // pings
- if (message.event === 'notification') {
- useNotificationsStore().addNewNotifications({
- timestamp: Date.now(),
- data: message.notification,
- })
- } else if (message.event === 'update') {
- useStatusesStore().addNewStatuses({
- timestamp: Date.now(),
- statuses: [message.status],
- userId: false,
- showImmediately: timelineData.visibleStatuses.length === 0,
- timeline: 'friends',
- })
- } else if (message.event === 'status.update') {
- useStatusesStore().addNewStatuses({
- timestamp: Date.now(),
- statuses: [message.status],
- userId: false,
- showImmediately:
- message.status.id in timelineData.visibleStatusesObject,
- timeline: 'friends',
- })
- } else if (message.event === 'delete') {
- dispatch('deleteStatusById', message.id)
- } else if (message.event === 'pleroma:chat_update') {
- // The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
- // The cause of the duplicates is the WS event arriving earlier than the HTTP response.
- // This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
- // (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
- setTimeout(() => {
- dispatch('addChatMessages', {
- chatId: message.chatUpdate.id,
- messages: [message.chatUpdate.lastMessage],
- })
- dispatch('updateChat', { chat: message.chatUpdate })
- maybeShowChatNotification(store, message.chatUpdate)
- }, 100)
- }
- },
- )
- state.mastoUserSocket.addEventListener('open', () => {
- // Do not show notification when we just opened up the page
- if (
- state.mastoUserSocketStatus !==
- WSConnectionStatus.STARTING_INITIAL
- ) {
- useInterfaceStore().pushGlobalNotice({
- level: 'success',
- messageKey: 'timeline.socket_reconnected',
- timeout: 5000,
- })
- }
- // Stop polling if we were errored or disabled
- if (
- new Set([
- WSConnectionStatus.ERROR,
- WSConnectionStatus.DISABLED,
- ]).has(state.mastoUserSocketStatus)
- ) {
- dispatch('stopFetchingTimeline', { timeline: 'friends' })
- dispatch('stopFetchingNotifications')
- useChatsStore().stopFetchingChats()
- }
- commit('resetRetryMultiplier')
- commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
- })
- state.mastoUserSocket.addEventListener(
- 'error',
- ({ detail: error }) => {
- console.error('Error in MastoAPI websocket:', error)
- // TODO is this needed?
- dispatch('clearOpenedChats')
- },
- )
- state.mastoUserSocket.addEventListener(
- 'close',
- ({ detail: 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`,
- )
- commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
- } else {
- console.warn(
- `MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`,
- )
- setTimeout(() => {
- dispatch('startMastoUserSocket')
- }, retryTimeout(state.retryMultiplier))
- commit('incrementRetryMultiplier')
- if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) {
- dispatch('startFetchingTimeline', { timeline: 'friends' })
- dispatch('startFetchingNotifications')
- useChatsStore().startFetchingChats()
- useInterfaceStore().pushGlobalNotice({
- level: 'error',
- messageKey: 'timeline.socket_broke',
- messageArgs: [code],
- timeout: 5000,
- })
- }
- commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
- }
- dispatch('clearOpenedChats')
- },
- )
- resolve()
- } catch (e) {
- reject(e)
- }
- })
- },
- stopMastoUserSocket({ state, dispatch }) {
- dispatch('startFetchingTimeline', { timeline: 'friends' })
- dispatch('startFetchingNotifications')
- useChatsStore().startFetchingChats()
- state.mastoUserSocket.close()
- },
-
// Notifications
startFetchingNotifications(store) {
if (store.state.fetchers.notifications) return
const fetcher = notificationsFetcher.startFetching({
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: 'notifications', fetcher })
},
stopFetchingNotifications(store) {
const fetcher = store.state.fetchers.notifications
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
},
// Follow requests
startFetchingFollowRequests(store) {
if (store.state.fetchers.followRequests) return
const fetcher = followRequestFetcher.startFetching({
store,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
},
stopFetchingFollowRequests(store) {
const fetcher = store.state.fetchers.followRequests
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
},
// Pleroma websocket
setWsToken(store, token) {
store.commit('setWsToken', token)
},
initializeSocket({ commit, state, rootState }) {
// Set up websocket connection
const token = state.wsToken
if (
useInstanceCapabilitiesStore().shoutAvailable &&
token !== undefined &&
state.socket === null
) {
const socket = new Socket('/socket', { params: { token } })
socket.connect()
commit('setSocket', socket)
useShoutStore().initializeShout(socket)
}
},
disconnectFromSocket({ commit, state }) {
state.socket?.disconnect()
commit('setSocket', null)
},
},
}
export default api
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index 331d0df7db..dfb202b4dd 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -1,145 +1,139 @@
import { camelCase } from 'lodash'
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,
-}, {
- maxId,
- sinceId,
- older = false,
- showImmediately = false,
-}) => {
+const fetchAndUpdate = (
+ { timeline, argument, credentials },
+ { maxId, sinceId, 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 = maxId || timeline.minId || null
} else {
if (sinceId === undefined) {
args.sinceId = timeline.maxId
} else if (sinceId !== null) {
args.sinceId = sinceId
}
}
args.withMuted = !hideMutedPosts
if (loggedIn && REPLY_VISIBILITY_TIMELINES.has(timeline)) {
args.replyVisibility = replyVisibility
}
const numStatusesBeforeFetch = timeline.statuses.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)
- useTimelinesStore().addStatusesToTimeline(
- timeline.name,
- argument,
- {
- statuses,
- showImmediately,
- pagination,
- }
- )
+ useTimelinesStore().addStatusesToTimeline(timeline.name, argument, {
+ statuses,
+ showImmediately,
+ pagination,
+ })
return { statuses, pagination }
})
.catch((error) => {
if (error.statusCode === 403 && timeline === 'favorites') {
useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable = false
return
}
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
+ interval: null,
}
const boundFetchAndUpdate = ({
showImmediately,
maxId,
sinceId,
older,
- } = {}) => fetchAndUpdate({
- timeline,
- argument,
- credentials,
- }, {
- maxId,
- sinceId,
- older,
- showImmediately,
- })
+ } = {}) =>
+ fetchAndUpdate(
+ {
+ timeline,
+ argument,
+ credentials,
+ },
+ {
+ maxId,
+ sinceId,
+ older,
+ showImmediately,
+ },
+ )
const startFetching = () => {
if (state.interval) throw new Error('Interval already exists!')
boundFetchAndUpdate({
- showImmediately: timeline.visibleStatusesIds.size === 0
+ showImmediately: timeline.visibleStatusesIds.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/notifications.js b/src/stores/notifications.js
index 034538dabb..d9c388dcfe 100644
--- a/src/stores/notifications.js
+++ b/src/stores/notifications.js
@@ -1,192 +1,194 @@
import { defineStore } from 'pinia'
import {
closeAllDesktopNotifications,
closeDesktopNotification,
} from '../services/desktop_notification_utils/desktop_notification_utils.js'
import {
isValidNotification,
maybeShowNotification,
} from '../services/notification_utils/notification_utils.js'
import { isStatusNotification } from '../services/notification_utils/notification_utils_sw.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { dismissNotification, markNotificationsAsSeen } from 'src/api/user.js'
export const defaultState = () => ({
desktopNotificationSilence: true,
maxId: 0,
minId: Number.POSITIVE_INFINITY,
data: [],
statusNotificationRelations: new WeakMap(),
idStore: new Map(),
loading: false,
})
export const useNotificationsStore = defineStore('notifications', {
state: defaultState,
actions: {
clearNotifications() {
const blankState = defaultState()
Object.keys(defaultState()).forEach((k) => {
this[k] = blankState[k]
})
},
updateNotificationsMinMaxId(id) {
this.maxId = id > this.maxId ? id : this.maxId
this.minId = id < this.minId ? id : this.minId
},
setNotificationsLoading(value) {
this.loading = value
},
setNotificationsSilence(value) {
this.desktopNotificationSilence = value
},
updateNotification({ id, updater }) {
const notification = this.idStore.get(id)
notification && updater(notification)
},
addNewNotifications(result) {
const { timestamp, data: notifications } = result
useUsersStore().addNewUsers({
timestamp,
data: notifications.map((n) => n.from_profile),
})
notifications.forEach(
(n) => (n.from_profile = useUsersStore().findUser(n.from_profile.id)),
)
const validNotifications = notifications.filter((notification) => {
// If invalid notification, update ids but don't add it to store
if (!isValidNotification(notification)) {
console.error('Invalid notification:', notification)
this.updateNotificationsMinMaxId(notification.id)
return false
}
return true
})
useUsersStore().addNewUsers({
timestamp,
- data: validNotifications.map((notification) => notification.from_profile),
+ data: validNotifications.map(
+ (notification) => notification.from_profile,
+ ),
})
const statusNotifications = validNotifications.filter(
(notification) =>
isStatusNotification(notification.type) && notification.status,
)
// Synchronous commit to add all the statuses
useStatusesStore().addNewStatuses({
timestamp,
statuses: statusNotifications.map(
(notification) => notification.status,
),
})
// Update references to statuses in notifications to ones in the store
statusNotifications.forEach((notification) => {
const id = notification.status.id
const referenceStatus = useStatusesStore().allStatuses.get(id)
if (referenceStatus) {
notification.status = referenceStatus
}
})
validNotifications.forEach((notification) => {
if (notification.type === 'pleroma:report') {
useReportsStore().addReport(notification.report)
}
if (notification.type === 'pleroma:emoji_reaction') {
useStatusesStore().fetchEmojiReactionsBy(notification.status.id)
}
// Only add a new notification if we don't have one for the same action
if (!this.idStore.has(notification.id)) {
this.updateNotificationsMinMaxId(notification.id)
notifications.forEach((notification) => {
this.data.push(notification)
this.idStore.set(notification.id, notification)
})
this.statusNotificationRelations.set(
notification.status,
this.idStore.get(notification.id),
)
maybeShowNotification(
useMergedConfigStore().mergedConfig.notificationVisibility,
Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters),
notification,
useI18nStore().i18n,
)
} else if (notification.seen) {
this.idStore.get(notification.id).seen = true
}
})
},
notificationClicked(id) {
const notification = this.idStore.get(id)
const { type, seen } = notification
if (!seen) {
switch (type) {
case 'mention':
case 'pleroma:report':
case 'follow_request':
break
default:
this.markSingleNotificationAsSeen({ id })
}
}
},
markNotificationsAsSeen() {
this.data.forEach((notification) => {
notification.seen = true
})
markNotificationsAsSeen({
id: this.maxId,
credentials: useUsersStore().currentUser.credentials,
}).then(() => {
closeAllDesktopNotifications()
})
},
markSingleNotificationAsSeen({ id }) {
const notification = this.idStore.get(id)
if (notification) notification.seen = true
markNotificationsAsSeen({
single: true,
id,
credentials: useUsersStore().currentUser.credentials,
}).then(() => {
closeDesktopNotification(id)
})
},
dismissNotificationLocal(id) {
this.data = this.data.filter((n) => n.id !== id)
delete this.idStore.delete(id)
},
dismissNotification(id) {
this.dismissNotificationLocal(id)
dismissNotification({
id,
credentials: useOAuthStore().token,
})
},
},
})
diff --git a/src/stores/statuses.js b/src/stores/statuses.js
index 699c2cf7c2..a7f17c15c8 100644
--- a/src/stores/statuses.js
+++ b/src/stores/statuses.js
@@ -1,602 +1,626 @@
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 { useUsersStore } from 'src/stores/users.js'
+import { useStreamingStore } from 'src/stores/streaming.js'
import {
fetchEmojiReactions,
fetchFavoritedByUsers,
fetchPinnedStatuses,
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(),
})
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: {
- addNewStatuses({ statuses, user = {}, userId, timestamp }) {
+ // Init
+ attachSocket() {
+ const et = new EventTarget()
+ const handleStatusMessage = ({ data, timestamp }) => {
+ console.log('STATUSES', data)
+ this.addNewStatuses({ statuses: [data.status], timestamp })
+ }
+
+ et.addEventListener('update', ({ detail: message }) => {
+ handleStatusMessage(message)
+ })
+
+ et.addEventListener('status.update', ({ detail: message }) => {
+ handleStatusMessage(message)
+ })
+
+ et.addEventListener('delete', ({ detail: message }) => {
+ console.log('DELETE', message)
+ this.deleteStatus(message.data)
+ })
+
+ useStreamingStore().addSubscriber({ et })
+ },
+ 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 === user.id) {
+ 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]
},
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,
)
},
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,
)
},
addEmojiReactionsBy({ id, emojiReactions }) {
const status = this.allStatuses.get(id)
status.emoji_reactions = emojiReactions
},
// Actions
/// Favorite
favorite(id) {
this.setFavorited({ id, value: true })
favorite({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unfavorite(id) {
this.setFavorited({ id, value: false })
unfavorite({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
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) {
this.setRetweeted({ id, value: true })
retweet({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unretweet(id) {
this.setRetweeted({ id, value: false })
unretweet({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
setRetweeted({ statusId, value }) {
const newStatus = this.allStatuses.get(statusId)
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
// React
reactWithEmoji(id, emoji) {
this.addOwnReaction({ id, emoji })
reactWithEmoji({
id,
emoji,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unreactWithEmoji(id, emoji) {
this.removeOwnReaction({ id, emoji })
unreactWithEmoji({
id,
emoji,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
addOwnReaction(id, emoji) {
const currentUser = useUsersStore().currentUser
const status = this.allStatuses.get(id)
const reactionIndex = status.emoji_reactions.findIndex(
(react) => react.name === emoji,
)
const reaction = status.emoji_reactions[reactionIndex] || {
name: emoji,
count: 0,
accounts: [],
}
const newReaction = {
...reaction,
count: reaction.count + 1,
me: true,
accounts: [...reaction.accounts, currentUser],
}
// Update count of existing reaction if it exists, otherwise append at the end
if (reactionIndex >= 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions.push(newReaction)
}
},
removeOwnReaction(id, emoji) {
const currentUser = useUsersStore().currentUser
const status = this.allStatuses.get(id)
const reactionIndex = status.emoji_reactions.findIndex(
(react) => react.name === emoji,
)
if (reactionIndex < 0) return
const reaction = status.emoji_reactions[reactionIndex]
const accounts = reaction.accounts || []
const newReaction = {
...reaction,
count: reaction.count - 1,
me: false,
accounts: accounts.filter((acc) => acc.id !== currentUser.id),
}
if (newReaction.count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = status.emoji_reactions.filter(
(r) => r.name !== emoji,
)
}
},
/// Bookmark
bookmark(id, bookmark_folder_id) {
this.setBookmarked({ id, value: true, bookmark_folder_id })
bookmarkStatus({
id,
folder_id: bookmark_folder_id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unbookmark(id) {
this.setBookmarked({ id, value: false })
unbookmarkStatus({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
setBookmarked({ id, value, bookmark_folder_id }) {
const status = this.allStatuses.get(id)
status.bookmarked = value
status.bookmark_folder_id = value ? bookmark_folder_id : null
},
/// Mute
muteConversation(id) {
return muteConversation({
id,
credentials: useOAuthStore().token,
})
.then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
return status
})
.then((status) => this.setMutedStatus(status))
},
unmuteConversation(id) {
return unmuteConversation({
id,
credentials: useOAuthStore().token,
})
.then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
return status
})
.then((status) => this.setMutedStatus(status))
},
setMutedStatus({ id, thread_muted }) {
// Setting thread_muted flag on all other known statuses
// belonging to same conversation
const newStatus = this.allStatuses.get(id)
newStatus.thread_muted = thread_muted
if (newStatus.thread_muted !== undefined) {
this.conversations
.get(newStatus.statusnet_conversation_id)
.forEach((status) => {
status.thread_muted = thread_muted
})
}
},
/// Pin
pinStatus(id) {
return pinOwnStatus({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
unpinStatus(id) {
return unpinOwnStatus({
id,
credentials: useOAuthStore().token,
}).then(({ data: status, timestamp }) => {
this.addNewStatuses({
statuses: [status],
timestamp,
})
})
},
/// 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
new file mode 100644
index 0000000000..0843980f2e
--- /dev/null
+++ b/src/stores/streaming.js
@@ -0,0 +1,189 @@
+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 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) {
+ this.socket.subscribe(...this.getSubArgs(stream))
+ subscriber.et.dispatchEvent(new CustomEvent('open'))
+ }
+ },
+ removeSubscriber(subscriber) {
+ const { stream, et } = subscriber
+
+ this.subscribers.delete(subscriber)
+ 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 CustomEvent('authenticated'))
+
+ if (stream) {
+ this.socket.subscribe(...this.getSubArgs(stream))
+ }
+ })
+ this.state = WSConnectionStatus.JOINED
+ },
+ onOpen() {
+ this.subscribers.forEach(({ stream, et }) => {
+ et.dispatchEvent(new CustomEvent('open'))
+ })
+ },
+ onMessage({ detail: message }) {
+ if (!message) return // pings
+ const timestamp = Date.now()
+ 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)
+
+ totalSubs.forEach(({ stream, et }) => {
+ et.dispatchEvent(new CustomEvent(
+ eventName,
+ {
+ detail: { streamName, streamArgument, data, timestamp },
+ }
+ ))
+ })
+
+ console.log('WS', message)
+ },
+ onError({ detail: error }) {
+ this.subscribers.forEach(({ stream, et }) => {
+ et.dispatchEvent(new CustomEvent('error', error))
+ })
+ console.error('Error in MastoAPI websocket:', error)
+ },
+ onClose({ detail: 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.subscribers.forEach(({ et }) => {
+ et.dispatchEvent(new CustomEvent('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 CustomEvent('close', closeEvent))
+ })
+ }
+
+ this.state = WSConnectionStatus.ERROR
+ }
+ },
+ },
+})
diff --git a/src/stores/timelines.js b/src/stores/timelines.js
index d78617a548..e9ad6853a7 100644
--- a/src/stores/timelines.js
+++ b/src/stores/timelines.js
@@ -1,234 +1,313 @@
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 { useUsersStore } from 'src/stores/users.js'
+import { useStatusesStore } from 'src/stores/statuses.js'
+import { useStreamingStore, TIMELINE_STREAM_MAP } from 'src/stores/streaming.js'
import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js'
const emptyTl = (name, argument = null) => {
const result = {
name,
statuses: new Map(),
visibleStatusesIds: new Set(),
newStatusCount: 0,
maxId: '',
minId: '',
minVisibleId: '',
loading: false,
+ streaming: false,
flushMarker: 0,
fetcher: 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([
- 'mentions',
- 'public',
- 'user',
- 'userPinned',
- 'media',
- 'favorites',
- 'publicAndExternal',
- 'friends',
- 'tag',
- 'dms',
- 'bookmarks',
- 'list',
- 'bubble',
- 'quotes',
- 'search',
- ].map((name) => [name, emptyTl(name)]))
+ return Object.fromEntries(
+ [...TIMELINES].map((name) => [name, emptyTl(name)]),
+ )
}
//const CUSTOM_SORT = new Set(['bookmarks', 'favorites'])
export const useTimelinesStore = defineStore('timelines', {
state: defaultState,
actions: {
addStatusesToTimeline(
timelineName,
argument,
{
statuses,
showImmediately = false,
noIdUpdate = false,
pagination = {},
nested = 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[name]
if (property && timeline[property] !== argument) {
return
}
if (!noIdUpdate) {
this.updateTimelineExtremes(
timeline,
statuses.map((x) => x.id),
pagination,
)
}
statuses.forEach((status) => {
const isNew = !timeline.statuses.has(status.id)
timeline.statuses.set(status.id, status)
if (isNew) {
if (showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
timeline.visibleStatusesIds.add(status.id)
} else {
// Just change newStatuscount
timeline.newStatusCount += 1
}
}
if (nested) return
// We are mentioned in a post
if (
status.type === 'status' &&
status.attentions.some(
({ id }) => id === useUsersStore().currentUser?.id,
)
) {
// Add the mention to the mentions timeline
if (timeline !== this.mentions) {
- this.addStatusesToTimeline('mentions', null, { statuses, nested: true })
+ this.addStatusesToTimeline('mentions', null, {
+ statuses,
+ nested: true,
+ })
}
}
if (status.visibility === 'direct') {
if (timeline !== this.dms) {
this.addStatusesToTimeline('dms', null, { statuses, nested: true })
}
}
})
},
- // Fetchers
- startFetchingTimeline(timelineName, argument) {
+ activatePersistents() {
+ TIMELINES.forEach(name => {
+ if (this[name].persistent) {
+ this.activate(name, undefined, true)
+ }
+ })
+ },
+
+ activate(timelineName, argument, persistent) {
const timeline = this[timelineName]
- if (timeline.fetcher) return
+ if (timeline.persistent && !persistent) return
if (
timelineName === 'favourites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
) {
return
}
timeline.fetcher = timelineFetcher(
timeline,
argument,
useOAuthStore().token,
)
+ this.startFetchingTimeline(timelineName, argument)
+
+ const streamName = TIMELINE_STREAM_MAP[timelineName]
+
+ if (streamName) {
+ const et = new EventTarget()
+ et.addEventListener('open', () => this.onStreamConnect(timelineName, argument))
+ et.addEventListener('close', () => this.onStreamDisconnect(timelineName, argument))
+ et.addEventListener('update', ({ detail: message }) => this.onStreamMessage(timelineName, argument, message))
+
+ timeline.socket = {
+ stream: {
+ name: streamName,
+ argument,
+ },
+ et,
+ }
+
+ useStreamingStore().addSubscriber(timeline.socket)
+ }
+ },
+ deactivate(timelineName) {
+ const timeline = this[timelineName]
+ if (timeline.persistent) return
+ this.clearTimeline(timelineName)
+ },
+
+ onStreamMessage(timeline, argument, event) {
+ // This relies on statuses store to process this event first
+ const status = useStatusesStore().allStatuses.get(event.data.status.id)
+
+ this.addStatusesToTimeline(timeline, argument, {
+ statuses: [status],
+ })
+ },
+
+ onStreamConnect(timeline) {
+ console.log('STREAM OK', timeline)
+ this[timeline].streaming = true
+ this.stopFetchingTimeline(timeline)
+ },
+
+ onStreamDisconnect(timeline, argument) {
+ console.log('STREAM DED', timeline, argument)
+ this[timeline].streaming = false
+ this.startFetchingTimeline(timeline, argument)
+ },
+
+ // Fetchers
+ startFetchingTimeline(timelineName, argument) {
+ console.log('START FETCHING', timelineName, argument)
+ const timeline = this[timelineName]
timeline.fetcher.startFetching()
},
stopFetchingTimeline(timelineName) {
+ console.log('STOP FETCHING', timelineName)
const timeline = this[timelineName]
- timeline.fetcher?.stopFetching()
- timeline.fetcher = null
+ timeline.fetcher.stopFetching()
},
// Queues & Timeline manip
updateTimelineExtremes(timeline, statuses, pagination = {}) {
// Can't use Math.min/max because it doesn't work with string (duh)
const minNew = pagination.maxId ?? min(...statuses) ?? ''
const maxNew = pagination.minId ?? max(...statuses) ?? ''
const newer = maxNew > timeline.maxId
const older = minNew < timeline.minId
if (newer || timeline.maxId === '') {
timeline.maxId = maxNew
}
if (older || timeline.minId === '') {
timeline.minId = minNew
}
},
resetStatuses() {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
this[key] = value
})
},
showNewStatuses(timelineName) {
const timeline = this[timelineName]
timeline.newStatusCount = 0
timeline.visibleStatusesIds = new Set(
[...timeline.statuses.keys()].slice(0, 50),
)
timeline.minVisibleId = last(timeline.visibleStatusesIds.keys())
timeline.minId = ''
timeline.maxId = ''
this.updateTimelineExtremes(timeline, [...timeline.statuses.keys()])
},
- clearTimeline(timeline, excludeUserId = false) {
- const userId = excludeUserId ? this[timeline].userId : undefined
- this.stopFetchingTimeline(timeline)
- this[timeline] = emptyTl(timeline, userId)
+ clearTimeline(timeline) {
+ console.log('CLEAR TIMELINE', timeline)
+ const data = this[timeline]
+ if (!data.streaming) {
+ this.stopFetchingTimeline(timeline)
+ }
+ if (data.socket) {
+ useStreamingStore().removeSubscriber(data.socket)
+ }
+ this[timeline] = emptyTl(timeline)
},
queueFlush(timeline, id) {
this[timeline].flushMarker = id
},
queueFlushAll() {
Object.keys(this).forEach((timeline) => {
this[timeline].flushMarker = this[timeline].maxId
})
},
// Misc
removeUserStatuses({ timelineName, userId }) {
const timeline = this.timelines[timelineName]
timeline.statuses
.values()
.filter(({ user }) => user.id === userId)
.forEach(({ id }) => {
timeline.statuses.delete(id)
timeline.visibleStatusesIds.delete(id)
})
timeline.minVisibleId =
timeline.visibleStatusesIds.size > 0
? last(timeline.visibleStatusesIds).id
: 0
timeline.maxId =
timeline.statuses.length > 0 ? first(timeline.statuses).id : 0
},
},
})
diff --git a/src/stores/users.js b/src/stores/users.js
index be9b91c71d..8de0c60a3a 100644
--- a/src/stores/users.js
+++ b/src/stores/users.js
@@ -1,823 +1,790 @@
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 { useTimelinesStore } from 'src/stores/timelines.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
+import { useTimelinesStore } from 'src/stores/timelines.js'
+import { useStreamingStore } from 'src/stores/streaming.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)
followersIds.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: unused0, ...oldUser } = existing
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) {
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) {
if (this.currentUser.blockIds.includes(blockId)) {
this.currentUser.blockIds.push(blockId)
}
},
setBlockIdsMaxId(blockIdsMaxId) {
this.currentUser.blockIdsMaxId = blockIdsMaxId
},
saveMuteIds(muteIds) {
this.currentUser.muteIds = muteIds
},
setMuteIdsMaxId(muteIdsMaxId) {
this.currentUser.muteIdsMaxId = muteIdsMaxId
},
addMuteId(muteId) {
if (this.currentUser.muteIds.includes(muteId)) {
this.currentUser.muteIds.push(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),
- )
+ }).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(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]
+ 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((relationship) =>
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(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]
+ data: [predictedRelationship],
})
this.addMuteId(id)
return muteUser({
id,
expiresIn,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) => {
this.updateUserRelationships(data)
this.addMuteId(id)
})
},
unmuteUser(id) {
const predictedRelationship = this.relationships[id] || { id }
predictedRelationship.muting = false
this.updateUserRelationships({
optimism: true,
- data: [predictedRelationship]
+ data: [predictedRelationship],
})
return unmuteUser({ id }).then(({ data: relationship }) =>
this.updateUserRelationships(data),
)
},
hideReblogs(id) {
return followUser({
id,
reblogs: false,
credentials: useOAuthStore().token,
- }).then(({ data: relationship }) =>
- this.updateUserRelationships(data),
- )
+ }).then(({ data: relationship }) => this.updateUserRelationships(data))
},
showReblogs(id) {
return followUser({
id,
reblogs: true,
credentials: useOAuthStore().token,
- }).then(({ data: relationship }) =>
- this.updateUserRelationships(data),
- )
+ }).then(({ data: relationship }) => 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: relationship }) =>
- this.updateUserRelationships(data),
- )
+ }).then(({ data: relationship }) => this.updateUserRelationships(data))
},
unsubscribeUser(id) {
return followUser({
id,
notify: false,
credentials: useOAuthStore().token,
- }).then(({ data: relationship }) =>
- this.updateUserRelationships(data),
- )
+ }).then(({ data: relationship }) => 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('disconnectFromSocket')
store.dispatch('stopFetchingTimeline', 'friends')
store.dispatch('stopFetchingNotifications')
useListsStore().stopFetching()
useBookmarkFoldersStore().stopFetching()
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
useStatusesStore().resetStatuses()
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 = []
user.muteIds = []
user.domainMutes = []
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)
-
- // Initialize the shout socket.
dispatch('initializeSocket')
}
- const startPolling = () => {
- // Start getting fresh posts.
- useTimelinesStore().startFetchingTimeline('friends')
+ // DMs and Home
+ useTimelinesStore().activatePersistents()
- // Start fetching notifications
- dispatch('startFetchingNotifications')
-
- if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
- // Start fetching chats
- useChatsStore().startFetchingChats()
- }
+ if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
+ // Start fetching chats
+ useChatsStore().startFetchingChats()
}
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
+ useStatusesStore().attachSocket()
+ //useNotificationsStore().attachSocket()
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
+ useStreamingStore().initSocket()
if (useMergedConfigStore().mergedConfig.useStreamingApi) {
- dispatch('fetchTimeline', {
- timeline: 'friends',
- sinceId: null,
- })
- dispatch('fetchNotifications', { sinceId: null })
- dispatch('enableMastoSockets', true)
- .catch((error) => {
- console.error(
- 'Failed initializing MastoAPI Streaming socket',
- error,
- )
- })
- .then(() => {
- dispatch('fetchChats', { latest: true })
- setTimeout(
- () =>
- useNotificationsStore().setNotificationsSilence(false),
- 10000,
- )
- })
- } else {
- startPolling()
+ useStreamingStore().initSocket()
}
// 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

Mime Type
text/x-diff
Expires
Sat, Oct 31, 1:00 PM (21 h, 26 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1801425
Default Alt Text
(110 KB)

Event Timeline