Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
123 KB
Referenced Files
None
Subscribers
None
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 3b21d874d9..8f8335c9c7 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -1,287 +1,310 @@
import AuthForm from 'src/components/auth_form/auth_form.js'
-import BookmarkTimeline from 'src/components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'src/components/conversation-page/conversation-page.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
-import QuotesTimeline from 'src/components/quotes_timeline/quotes_timeline.vue'
import RemoteUserResolver from 'src/components/remote_user_resolver/remote_user_resolver.vue'
-import TagTimeline from 'src/components/tag_timeline/tag_timeline.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: () => ({
- timelineName: 'publicAndExternal',
+ timelineRef: { name: 'publicAndExternal' },
}),
},
{
name: 'public-timeline',
path: '/main/public',
component: Timeline,
props: () => ({
- timelineName: 'public',
+ timelineRef: { name: 'public' },
}),
},
{
name: 'friends',
path: '/main/friends',
component: Timeline,
beforeEnter: validateAuthenticatedRoute,
props: () => ({
- timelineName: 'friends',
+ 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: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
- { name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{
name: 'bubble',
path: '/bubble',
component: Timeline,
props: () => ({
- timelineName: 'bubble',
+ 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: QuotesTimeline },
+ {
+ 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: () => ({
- timelineName: 'dms',
+ 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: () =>
- import('src/components/lists_timeline/lists_timeline.vue'),
+ component: Timeline,
+ props: (route) => ({
+ timelineRef: { name: 'lists', 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: BookmarkTimeline,
+ 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/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js
deleted file mode 100644
index dbda08ba12..0000000000
--- a/src/components/bookmark_timeline/bookmark_timeline.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import Timeline from 'src/components/timeline/timeline.vue'
-
-import { useStatusesStore } from 'src/stores/statuses.js'
-
-const Bookmarks = {
- created() {
- useStatusesStore().clearTimeline({ timeline: 'bookmarks' })
- this.$store.dispatch('startFetchingTimeline', {
- timeline: 'bookmarks',
- bookmarkFolderId: this.folderId || null,
- })
- },
- components: {
- Timeline,
- },
- computed: {
- folderId() {
- return this.$route.params.id
- },
- timeline() {
- return this.$store.state.statuses.timelines.bookmarks
- },
- },
- watch: {
- folderId() {
- useStatusesStore().clearTimeline({ timeline: 'bookmarks' })
- this.$store.dispatch('stopFetchingTimeline', 'bookmarks')
- this.$store.dispatch('startFetchingTimeline', {
- timeline: 'bookmarks',
- bookmarkFolderId: this.folderId || null,
- })
- },
- },
- unmounted() {
- useStatusesStore().clearTimeline({ timeline: 'bookmarks' })
- this.$store.dispatch('stopFetchingTimeline', 'bookmarks')
- },
-}
-
-export default Bookmarks
diff --git a/src/components/bookmark_timeline/bookmark_timeline.vue b/src/components/bookmark_timeline/bookmark_timeline.vue
deleted file mode 100644
index cc86cf12e9..0000000000
--- a/src/components/bookmark_timeline/bookmark_timeline.vue
+++ /dev/null
@@ -1,10 +0,0 @@
-<template>
- <Timeline
- :title="$t('nav.bookmarks')"
- :timeline="timeline"
- :timeline-name="'bookmarks'"
- :bookmark-folder-id="folderId"
- />
-</template>
-
-<script src="./bookmark_timeline.js"></script>
diff --git a/src/components/bubble_timeline/bubble_timeline.js b/src/components/bubble_timeline/bubble_timeline.js
deleted file mode 100644
index fcbc31ad23..0000000000
--- a/src/components/bubble_timeline/bubble_timeline.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Timeline from 'src/components/timeline/timeline.vue'
-
-const BubbleTimeline = {
- components: {
- Timeline,
- },
- computed: {
- timeline() {
- return this.$store.state.statuses.timelines.bubble
- },
- },
- created() {
- this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' })
- },
- unmounted() {
- this.$store.dispatch('stopFetchingTimeline', 'bubble')
- },
-}
-
-export default BubbleTimeline
diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js
deleted file mode 100644
index ae58e58ed0..0000000000
--- a/src/components/lists_timeline/lists_timeline.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import Timeline from 'src/components/timeline/timeline.vue'
-
-import { useListsStore } from 'src/stores/lists.js'
-import { useStatusesStore } from 'src/stores/statuses.js'
-
-const ListsTimeline = {
- data() {
- return {
- listId: null,
- }
- },
- components: {
- Timeline,
- },
- computed: {
- timeline() {
- return this.$store.state.statuses.timelines.list
- },
- },
- watch: {
- $route: function (route) {
- if (route.name === 'lists-timeline' && route.params.id !== this.listId) {
- this.listId = route.params.id
- this.$store.dispatch('stopFetchingTimeline', 'list')
- useStatusesStore().clearTimeline({ timeline: 'list' })
- useListsStore().fetchList({ listId: this.listId })
- this.$store.dispatch('startFetchingTimeline', {
- timeline: 'list',
- listId: this.listId,
- })
- }
- },
- },
- created() {
- this.listId = this.$route.params.id
- useListsStore().fetchList({ listId: this.listId })
- this.$store.dispatch('startFetchingTimeline', {
- timeline: 'list',
- listId: this.listId,
- })
- },
- unmounted() {
- this.$store.dispatch('stopFetchingTimeline', 'list')
- useStatusesStore().clearTimeline({ timeline: 'list' })
- },
-}
-
-export default ListsTimeline
diff --git a/src/components/lists_timeline/lists_timeline.vue b/src/components/lists_timeline/lists_timeline.vue
deleted file mode 100644
index 18156b8120..0000000000
--- a/src/components/lists_timeline/lists_timeline.vue
+++ /dev/null
@@ -1,10 +0,0 @@
-<template>
- <Timeline
- title="list.name"
- :timeline="timeline"
- :list-id="listId"
- timeline-name="list"
- />
-</template>
-
-<script src="./lists_timeline.js"></script>
diff --git a/src/components/mentions/mentions.js b/src/components/mentions/mentions.js
deleted file mode 100644
index 48e06a950c..0000000000
--- a/src/components/mentions/mentions.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Timeline from 'src/components/timeline/timeline.vue'
-
-const Mentions = {
- computed: {
- timeline() {
- return this.$store.state.statuses.timelines.mentions
- },
- },
- components: {
- Timeline,
- },
-}
-
-export default Mentions
diff --git a/src/components/mentions/mentions.vue b/src/components/mentions/mentions.vue
deleted file mode 100644
index 70f60bafe6..0000000000
--- a/src/components/mentions/mentions.vue
+++ /dev/null
@@ -1,9 +0,0 @@
-<template>
- <Timeline
- :title="$t('nav.interactions')"
- :timeline="timeline"
- :timeline-name="'mentions'"
- />
-</template>
-
-<script src="./mentions.js"></script>
diff --git a/src/components/quotes_timeline/quotes_timeline.js b/src/components/quotes_timeline/quotes_timeline.js
deleted file mode 100644
index 4778f1daa7..0000000000
--- a/src/components/quotes_timeline/quotes_timeline.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Timeline from 'src/components/timeline/timeline.vue'
-
-import { useStatusesStore } from 'src/stores/statuses.js'
-
-const QuotesTimeline = {
- created() {
- useStatusesStore().clearTimeline({ timeline: 'tag' })
- this.$store.dispatch('startFetchingTimeline', {
- timeline: 'quotes',
- statusId: this.statusId,
- })
- },
- components: {
- Timeline,
- },
- computed: {
- statusId() {
- return this.$route.params.id
- },
- timeline() {
- return this.$store.state.statuses.timelines.quotes
- },
- },
- watch: {
- statusId() {
- useStatusesStore().clearTimeline({ timeline: 'tag' })
- this.$store.dispatch('startFetchingTimeline', {
- timeline: 'quotes',
- statusId: this.statusId,
- })
- },
- },
- unmounted() {
- this.$store.dispatch('stopFetchingTimeline', 'quotes')
- },
-}
-
-export default QuotesTimeline
diff --git a/src/components/quotes_timeline/quotes_timeline.vue b/src/components/quotes_timeline/quotes_timeline.vue
deleted file mode 100644
index 835abd12e2..0000000000
--- a/src/components/quotes_timeline/quotes_timeline.vue
+++ /dev/null
@@ -1,10 +0,0 @@
-<template>
- <Timeline
- :title="$t('nav.quotes')"
- :timeline="timeline"
- :timeline-name="'quotes'"
- :status-id="statusId"
- />
-</template>
-
-<script src='./quotes_timeline.js'></script>
diff --git a/src/components/tag_timeline/tag_timeline.js b/src/components/tag_timeline/tag_timeline.js
deleted file mode 100644
index da3fcb6936..0000000000
--- a/src/components/tag_timeline/tag_timeline.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import Timeline from 'src/components/timeline/timeline.vue'
-
-import { useStatusesStore } from 'src/stores/statuses.js'
-
-const TagTimeline = {
- created() {
- useStatusesStore().clearTimeline({ timeline: 'tag' })
- this.$store.dispatch('startFetchingTimeline', {
- timeline: 'tag',
- tag: this.tag,
- })
- },
- components: {
- Timeline,
- },
- computed: {
- tag() {
- return this.$route.params.tag
- },
- timeline() {
- return this.$store.state.statuses.timelines.tag
- },
- },
- watch: {
- tag() {
- useStatusesStore().clearTimeline({ timeline: 'tag' })
- this.$store.dispatch('startFetchingTimeline', {
- timeline: 'tag',
- tag: this.tag,
- })
- },
- },
- unmounted() {
- this.$store.dispatch('stopFetchingTimeline', 'tag')
- },
-}
-
-export default TagTimeline
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index e3ba35e07a..040ae80e7d 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,345 +1,303 @@
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 timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.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: {
- timelineName: String,
- userId: String,
- listId: String,
- statusId: String,
- bookmarkFolderId: String,
- tag: String,
+ timelineRef: Object,
+ argument: String,
embedded: Boolean,
count: Number,
pinnedStatusIds: Set,
inProfile: Boolean,
footerSlipgate: Object, // reference to an element where we should put our footer
},
data() {
return {
showScrollTop: false,
paused: false,
unfocused: false,
bottomedOut: false,
virtualScrollIndex: 0,
blockingClicks: false,
}
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
computed: {
timeline() {
- return useStatusesStore().timelines[this.timelineName]
+ return useTimelinesStore()[this.timelineRef.name]
},
filteredVisibleStatuses() {
- return [...this.timeline.visibleStatuses.values()].filter(
- (status) =>
- this.timelineName !== 'user' ||
- (status.id >= this.timeline.minId &&
- status.id <= this.timeline.maxId),
- )
+ return [...this.timeline.visibleStatusesIds.keys()]
+ .filter(
+ (id) =>
+ this.timelineRef.name !== 'user' ||
+ (id >= this.timeline.minId && id <= this.timeline.maxId),
+ )
+ .map((id) => this.timeline.statuses.get(id))
},
filteredPinnedStatusIds() {
return (this.pinnedStatusIds || []).filter(
(statusId) => this.timeline.statusesObject[statusId],
)
},
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'],
),
}
},
// id map of statuses which need to be hidden in the main list due to pinning logic
pinnedStatusIdsObject() {
return keyBy(this.pinnedStatusIds)
},
statusesToDisplay() {
- const amount = this.timeline.visibleStatuses.size
+ const amount = this.timeline.visibleStatusesIds.size
const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
const nonPinnedIndex =
this.virtualScrollIndex - this.filteredPinnedStatusIds.length
const min = Math.max(0, nonPinnedIndex - statusesPerSide)
const max = Math.min(amount, nonPinnedIndex + statusesPerSide)
- return new Set([...this.timeline.visibleStatuses.keys()].slice(min, max))
+ return new Set(
+ [...this.timeline.visibleStatusesIds.keys()].slice(min, max),
+ )
},
virtualScrollingEnabled() {
return useMergedConfigStore().mergedConfig.virtualScrolling
},
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
created() {
- const store = this.$store
- const credentials = useUsersStore().currentUser.credentials
- const showImmediately = this.timeline.visibleStatuses.length === 0
-
- window.addEventListener('scroll', this.handleScroll)
-
- if (store.state.api.fetchers[this.timelineName]) {
- return false
- }
-
- timelineFetcher.fetchAndUpdate({
- credentials,
- timeline: this.timelineName,
- showImmediately,
- userId: this.userId,
- listId: this.listId,
- statusId: this.statusId,
- bookmarkFolderId: this.bookmarkFolderId,
- tag: this.tag,
- })
+ 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() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('keydown', this.handleShortKey)
if (document.hidden !== undefined)
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
- useStatusesStore().setLoading({
- timeline: this.timelineName,
- value: false,
- })
},
methods: {
+ timelineChange(newTimeline, oldTimeline) {
+ if (oldTimeline && oldTimeline.name !== 'friends') {
+ useTimelinesStore().clearTimeline(oldTimeline.name)
+ }
+ 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) {
- useStatusesStore().clearTimeline({
- timeline: this.timelineName,
- excludeUserId: true,
- })
- useStatusesStore().queueFlush({ timeline: this.timelineName, id: 0 })
- if (this.timelineName === 'user') {
- this.$store.dispatch('fetchPinnedStatuses', this.userId)
- }
+ useTimelinesStore().clearTimeline(this.timelineRef.name)
+ useTimelinesStore().queueFlush(this.timelineRef.name, '')
this.fetchOlderStatuses()
} else {
this.blockClicksTemporarily()
- useStatusesStore().showNewStatuses(this.timelineName)
+ useTimelinesStore().showNewStatuses(this.timelineRef.name)
this.paused = false
}
window.scrollTo({ top: 0 })
},
fetchOlderStatuses: throttle(
function () {
- const credentials = useUsersStore().currentUser.credentials
- useStatusesStore().setLoading({
- timeline: this.timelineName,
- value: true,
- })
- timelineFetcher
- .fetchAndUpdate({
- credentials,
- timeline: this.timelineName,
- older: true,
- showImmediately: true,
- userId: this.userId,
- listId: this.listId,
- statusId: this.statusId,
- bookmarkFolderId: this.bookmarkFolderId,
- tag: this.tag,
- })
+ this.timeline.fetcher
+ .fetchAndUpdate()
.then(({ statuses }) => {
if (statuses?.length === 0) {
this.bottomedOut = true
}
})
- .finally(() =>
- useStatusesStore().setLoading({
- timeline: this.timelineName,
- value: false,
- }),
- )
},
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.floor(statuses.length * (centerOfScreen / height))
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 49065c2a9f..1b6b5f31c4 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -1,208 +1,180 @@
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 { useStatusesStore } from 'src/stores/statuses.js'
+import { useTimelinesStore } from 'src/stores/statuses.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() {
this.stopFetching()
useInterfaceStore().setForeignProfileBackground(null)
this.$store.dispatch('clearFollowers', this.userId)
this.$store.dispatch('clearFriends', this.userId)
},
computed: {
- timeline() {
- return this.$store.state.statuses.timelines.user
- },
favorites() {
- return this.$store.state.statuses.timelines.favorites
+ return useTimelinesStore().favorites
},
media() {
- return this.$store.state.statuses.timelines.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 get(useUsersStore().findUser(this.userId), 'friendIds', []).map(
(id) => useUsersStore().findUser(id),
)
},
followers() {
return get(useUsersStore().findUser(this.userId), 'followerIds', []).map(
(id) => useUsersStore().findUser(id),
)
},
},
methods: {
setFooterRef(el) {
this.footerRef = el
},
fetchUsers(group) {
return () =>
- this.$store
- .dispatch('fetch' + group, this.userId)
+ useUsersStore()['fetch' + group](this.userId)
.then((result) => ({ items: result }))
},
load(userNameOrId) {
- const startFetchingTimeline = (timeline, userId) => {
- // Clear timeline only if load another user's profile
- if (userId !== this.$store.state.statuses.timelines[timeline].userId) {
- useStatusesStore().clearTimeline({ timeline: 'user' })
- useStatusesStore().clearTimeline({ timeline: 'userPinned' })
- useStatusesStore().clearTimeline({ timeline: 'media' })
- }
- this.$store.dispatch('startFetchingTimeline', { timeline, userId })
- }
-
const loadById = (userId) => {
this.userId = userId
- startFetchingTimeline('user', userId)
- startFetchingTimeline('media', userId)
- if (this.isUs) {
- startFetchingTimeline('favorites')
- } else if (!this.user.hide_favorites) {
- startFetchingTimeline('favorites', userId)
- }
- // Fetch all pinned statuses immediately
- this.$store.dispatch('fetchPinnedStatuses', 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 {
;(maybeId
? this.$store.dispatch('fetchUser', maybeId)
: this.$store.dispatch('fetchUserByName', maybeName)
)
.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')
}
})
}
},
- stopFetching() {
- this.$store.dispatch('stopFetchingTimeline', 'user')
- this.$store.dispatch('stopFetchingTimeline', 'favorites')
- this.$store.dispatch('stopFetchingTimeline', 'media')
- },
switchUser(userNameOrId) {
- this.stopFetching()
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/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 6ca0c39bd7..e7381937d6 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -1,129 +1,128 @@
<template>
<div>
<div
v-if="user"
class="user-profile panel panel-default"
>
<div class="panel-body card-wrapper">
<UserCard
:user-id="userId"
:switcher="true"
- :selected="timeline.viewing"
:compact="compactProfiles"
avatar-action="zoom"
:has-note-editor="true"
/>
</div>
<tab-switcher
:active-tab="tab"
:render-only-focused="true"
:on-switch="onTabSwitch"
>
- <Timeline
- key="statuses"
+ <div
+ class="statuses"
:label="$t('user_card.statuses')"
:count="user.statuses_count"
- :embedded="true"
:title="$t('user_profile.timeline_title')"
- :timeline="timeline"
- timeline-name="user"
- :user-id="userId"
- :pinned-status-ids="user.pinnedStatusIds"
- :in-profile="true"
- :footer-slipgate="footerRef"
- />
+ >
+ <Timeline
+ key="statuses"
+ :embedded="true"
+ timeline-name="user"
+ :argument="userId"
+ :in-profile="true"
+ :footer-slipgate="footerRef"
+ />
+ </div>
<div
v-if="followsTabVisible"
key="followees"
class="panel-body"
:label="$t('user_card.followees')"
:disabled="!user.friends_count"
>
<List
:fetch-function="fetchUsers('Friends')"
:external-items="friends"
>
<template #item="{item}">
<FollowCard :user="item" />
</template>
</List>
</div>
<div
v-if="followersTabVisible"
key="followers"
class="panel-body"
:label="$t('user_card.followers')"
:disabled="!user.followers_count"
>
<List
:fetch-function="fetchUsers('Followers')"
:external-items="followers"
>
<template #item="{item}">
<FollowCard
:user="item"
:no-follows-you="isUs"
/>
</template>
</List>
</div>
<Timeline
key="media"
:label="$t('user_card.media')"
:disabled="!media.visibleStatuses.length"
:embedded="true"
:title="$t('user_card.media')"
timeline-name="media"
- :timeline="media"
- :user-id="userId"
+ :argument="userId"
:in-profile="true"
:footer-slipgate="footerRef"
/>
<Timeline
v-if="favoritesTabVisible"
key="favorites"
:label="$t('user_card.favorites')"
:disabled="!favorites.visibleStatuses.length"
:embedded="true"
:title="$t('user_card.favorites')"
timeline-name="favorites"
- :timeline="favorites"
- :user-id="isUs ? undefined : userId"
+ :argument="isUs ? undefined : userId"
:in-profile="true"
:footer-slipgate="footerRef"
/>
</tab-switcher>
<div
:ref="setFooterRef"
class="panel-footer"
/>
</div>
<div
v-else
class="panel user-profile-placeholder"
>
<div class="panel-heading">
<h1 class="title">
{{ $t('settings.profile_tab') }}
</h1>
</div>
<div class="panel-body">
<div
v-if="error"
class="alert error"
>
<span class="error-message">{{ error }}</span>
</div>
<FAIcon
v-else
spin
icon="circle-notch"
/>
</div>
</div>
</div>
</template>
<script src="./user_profile.js"></script>
<style src="./user_profile.scss" lang="scss"></style>
diff --git a/src/modules/api.js b/src/modules/api.js
index e686df0fe4..7a69e464e2 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,346 +1,297 @@
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 { fetchTimeline } from 'src/api/timelines.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'
-import timelineFetcher from 'src/services/timeline_fetcher/timeline_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')
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')
state.mastoUserSocket.close()
},
- // Timelines
- startFetchingTimeline(
- store,
- {
- timeline = 'friends',
- tag = false,
- userId = false,
- listId = false,
- statusId = false,
- bookmarkFolderId = false,
- },
- ) {
- if (
- timeline === 'favourites' &&
- !useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
- )
- return
- if (store.state.fetchers[timeline]) return
-
- const fetcher = timelineFetcher.startFetching({
- timeline,
- store,
- userId,
- listId,
- statusId,
- bookmarkFolderId,
- tag,
- credentials: useOAuthStore().token,
- })
-
- store.commit('addFetcher', { fetcherName: timeline, fetcher })
- },
- stopFetchingTimeline(store, timeline) {
- const fetcher = store.state.fetchers[timeline]
- if (!fetcher) return
- store.commit('removeFetcher', { fetcherName: timeline, fetcher })
- },
-
- fetchTimeline(store, { timeline, ...rest }) {
- fetchTimeline({
- store,
- timeline,
- ...rest,
- credentials: useOAuthStore().token,
- })
- },
-
// 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/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 5157504b26..1fdb7bec1c 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,118 +1,121 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
import { muteFilterHits } from '../status_parser/status_parser.js'
-import { prepareNotificationObject, isStatusNotification } from './notification_utils_sw.js'
+import {
+ isStatusNotification,
+ prepareNotificationObject,
+} from './notification_utils_sw.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
export const ACTIONABLE_NOTIFICATION_TYPES = new Set([
'mention',
'pleroma:report',
'follow_request',
])
const visibleTypes = (notificationVisibility) => {
return [
notificationVisibility.likes && 'like',
notificationVisibility.mentions && 'mention',
notificationVisibility.statuses && 'status',
notificationVisibility.repeats && 'repeat',
notificationVisibility.follows && 'follow',
notificationVisibility.followRequest && 'follow_request',
notificationVisibility.moves && 'move',
notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
notificationVisibility.reports && 'pleroma:report',
notificationVisibility.polls && 'poll',
].filter(Boolean)
}
export const isValidNotification = (notification) => {
if (isStatusNotification(notification.type) && !notification.status) {
return false
}
return true
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const isMutedNotification = (muteFilters, notification) => {
if (!notification.status) return false
if (notification.status.muted) return true
return muteFilterHits(muteFilters, notification.status).length > 0
}
export const maybeShowNotification = (
notificationVisibility,
muteFilters,
notification,
i18n,
) => {
if (notification.seen) return
if (!visibleTypes(notificationVisibility).includes(notification.type)) return
if (
notification.type === 'mention' &&
isMutedNotification(muteFilters, notification)
)
return
const notificationObject = prepareNotificationObject(notification, i18n)
showDesktopNotification(notificationObject)
}
export const filteredNotifications = (notificationVisibility, types) => {
// map is just to clone the array since sort mutates it and it causes some issues
const sortedNotifications = useNotificationsStore().data.sort(sortById)
// TODO implement sorting elsewhere and make it optional
return sortedNotifications.filter((notification) =>
(types || visibleTypes(notificationVisibility)).includes(notification.type),
)
}
export const unseenNotifications = (
notificationVisibility,
ignoreInactionableSeen,
) => {
return filteredNotifications(notificationVisibility).filter(
({ seen, type }) => {
if (!ignoreInactionableSeen) return !seen
if (seen) return false
return ACTIONABLE_NOTIFICATION_TYPES.has(type)
},
)
}
export const countExtraNotifications = (
store,
mergedConfig,
unreadChatsCount,
unreadAnnouncementCount,
) => {
const rootGetters = store.rootGetters || store.getters
if (!mergedConfig.showExtraNotifications) {
return 0
}
return [
mergedConfig.showChatsInExtraNotifications ? unreadChatsCount : 0,
mergedConfig.showAnnouncementsInExtraNotifications
? unreadAnnouncementCount
: 0,
mergedConfig.showFollowRequestsInExtraNotifications
? rootGetters.followRequestCount
: 0,
].reduce((a, c) => a + c, 0)
}
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index b7097f4e07..84c9805567 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -1,155 +1,147 @@
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 update = ({
- statuses,
- timeline,
- showImmediately,
- userId,
- listId,
- pagination,
-}) => {
- const ccTimeline = camelCase(timeline)
-
- useStatusesStore().addNewStatuses({
- timelineName: ccTimeline,
- userId,
- listId,
- statuses,
- showImmediately,
- pagination,
- })
-}
+const REPLY_VISIBILITY_TIMELINES = new Set([
+ 'friends',
+ 'public',
+ 'publicAndExternal',
+ 'bubble',
+])
const fetchAndUpdate = ({
+ timeline,
+ argument,
credentials,
- timeline = 'friends',
- older = false,
- showImmediately = false,
- userId,
- listId,
- statusId,
- bookmarkFolderId,
- tag,
+}, {
maxId,
sinceId,
+ older = false,
+ showImmediately = false,
}) => {
- const args = { timeline, credentials }
- const timelineData = useStatusesStore().timelines[camelCase(timeline)]
+ timeline.loading = true
const { hideMutedPosts, replyVisibility } =
useMergedConfigStore().mergedConfig
- const loggedIn = !!useUsersStore().currentUser
+ const loggedIn = useUsersStore().loggedIn
+
+ const args = { timeline: timeline.name, credentials }
+ args[ARGUMENT_MAP[timeline.name]] = argument
if (older) {
// When minId = 0 we need to fetch without maxId param
- args.maxId = maxId || timelineData.minId || null
+ args.maxId = maxId || timeline.minId || null
} else {
if (sinceId === undefined) {
- args.sinceId = timelineData.maxId
+ args.sinceId = timeline.maxId
} else if (sinceId !== null) {
args.sinceId = sinceId
}
}
- args.userId = userId
- args.listId = listId
- args.statusId = statusId
- args.bookmarkFolderId = bookmarkFolderId
- args.tag = tag
args.withMuted = !hideMutedPosts
- if (
- loggedIn &&
- ['friends', 'public', 'publicAndExternal', 'bubble'].includes(timeline)
- ) {
+ if (loggedIn && REPLY_VISIBILITY_TIMELINES.has(timeline)) {
args.replyVisibility = replyVisibility
}
- const numStatusesBeforeFetch = timelineData.statuses.length
+ const numStatusesBeforeFetch = timeline.statuses.size
return fetchTimeline(args)
.then((response) => {
- const { data: statuses, pagination } = response
+ const { data: statuses, pagination, timestamp } = response
if (
!older &&
statuses.length >= 20 &&
- !timelineData.loading &&
+ !timeline.loading &&
numStatusesBeforeFetch > 0
) {
- useStatusesStore().queueFlush({ timeline, id: timelineData.maxId })
+ useTimelinesStore().queueFlush(timeline.name, timeline.maxId)
}
- update({
- statuses,
- timeline,
- showImmediately,
- userId,
- listId,
- pagination,
- })
+
+ const processed = useStatusesStore()
+ .addNewStatuses({ statuses, timestamp })
+ .filter(Boolean)
+
+ console.log(timeline.name, argument, showImmediately, processed.length)
+
+ 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 startFetching = ({
- timeline = 'friends',
- credentials,
- userId,
- listId,
- statusId,
- bookmarkFolderId,
- tag,
-}) => {
- const timelineData = useStatusesStore().timelines[camelCase(timeline)]
- const showImmediately = timelineData.visibleStatuses.size === 0
- console.log(timeline)
- timelineData.userId = userId
- timelineData.listId = listId
- timelineData.bookmarkFolderId = bookmarkFolderId
- fetchAndUpdate({
+const timelineFetcher = (timeline, argument, argumentKey, credentials) => {
+ const state = {
+ interval: null
+ }
+
+ const boundFetchAndUpdate = ({
+ showImmediately,
+ maxId,
+ sinceId,
+ older,
+ } = {}) => fetchAndUpdate({
timeline,
+ argument,
+ argumentKey,
credentials,
+ }, {
+ maxId,
+ sinceId,
+ older,
showImmediately,
- userId,
- listId,
- statusId,
- bookmarkFolderId,
- tag,
})
- const boundFetchAndUpdate = () =>
- fetchAndUpdate({
- timeline,
- credentials,
- userId,
- listId,
- statusId,
- bookmarkFolderId,
- tag,
+
+ const startFetching = () => {
+ if (state.interval) throw new Error('Interval already exists!')
+
+ boundFetchAndUpdate({
+ showImmediately: timeline.visibleStatusesIds.size === 0
})
- return promiseInterval(boundFetchAndUpdate, 10000)
-}
-const timelineFetcher = {
- fetchAndUpdate,
- startFetching,
+
+ 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 c45f5b0a3a..372700f110 100644
--- a/src/stores/notifications.js
+++ b/src/stores/notifications.js
@@ -1,189 +1,187 @@
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 { 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
})
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 55060e2511..699c2cf7c2 100644
--- a/src/stores/statuses.js
+++ b/src/stores/statuses.js
@@ -1,803 +1,602 @@
-import { first, last, maxBy, minBy } from 'lodash'
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 {
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'
-const emptyTl = (userId) => ({
- statuses: new Map(),
- faves: [],
- visibleStatuses: new Map(),
- newStatusCount: 0,
- maxId: '',
- minId: '',
- minVisibleId: 0,
- loading: false,
- followers: [],
- friends: [],
- userId,
- flushMarker: 0,
-})
-
export const defaultState = () => ({
allStatuses: new Map(),
timestamps: new WeakMap(),
scrobblesNextFetch: {},
conversations: new Map(),
favorites: new Set(),
- timelines: {
- mentions: emptyTl(),
- public: emptyTl(),
- user: emptyTl(),
- userPinned: emptyTl(),
- favorites: emptyTl(),
- media: emptyTl(),
- publicAndExternal: emptyTl(),
- friends: emptyTl(),
- tag: emptyTl(),
- dms: emptyTl(),
- bookmarks: emptyTl(),
- list: emptyTl(),
- bubble: emptyTl(),
- },
})
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)
})
}
-const USER_TIMELINES = new Set(['user', 'userPinned', 'media'])
-
export const useStatusesStore = defineStore('statuses', {
state: defaultState,
actions: {
- addNewStatuses({
- statuses,
- showImmediately = false,
- timelineName,
- user = {},
- userId,
- noIdUpdate = false,
- pagination = {},
- timestamp,
- }) {
+ addNewStatuses({ statuses, user = {}, userId, timestamp }) {
// Sanity check
if (!Array.isArray(statuses)) {
- return false
- }
-
- const timeline = this.timelines[timelineName]
-
- if (timeline && !noIdUpdate && statuses.length > 0) {
- this.updateTimelineExtremes(timeline, statuses, pagination)
+ throw new TypeError("Statuses aren't an array!")
}
- // 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
- if (USER_TIMELINES.has(timelineName) && timeline.userId !== userId) {
- return
- }
-
- const addStatus = (data, showImmediately, addToTimeline = true) => {
+ // 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]]))
}
- // We are mentioned in a post
- if (
- status.type === 'status' &&
- status.attentions.some(({ id }) => id === user.id)
- ) {
- const mentions = this.timelines.mentions
-
- // Add the mention to the mentions timeline
- if (timeline !== mentions) {
- const [, isNew] = this.mergeOrAdd(mentions.statuses, data)
- if (isNew) mentions.newStatusCount += 1
- }
- }
-
- if (status.visibility === 'direct') {
- const dms = this.timelines.dms
-
- const [, isNew] = this.mergeOrAdd(dms.statuses, data)
- if (isNew) dms.newStatusCount += 1
- }
-
- // Some statuses should only be added to the global status repository.
- if (timeline && addToTimeline) {
- // Decide if we should treat the status as new for this timeline.
- const [status, isNew] = this.mergeOrAdd(timeline.statuses, data)
- if (isNew) {
- if (showImmediately) {
- // Add it directly to the visibleStatuses, don't change
- // newStatusCount
- timeline.visibleStatuses.set(status.id, status)
- } else {
- // Just change newStatuscount
- timeline.newStatusCount += 1
- }
- }
- }
-
+ // Work on quote
if (status.quote) {
- addStatus(
- status.quote,
- /* showImmediately = */ false,
- /* addToTimeline = */ false,
- )
+ status.quote = addStatus(status.quote)
}
return status
}
const processors = {
status: (status) => {
- addStatus(status, showImmediately)
+ return addStatus(status)
},
edit: (status) => {
- addStatus(status, showImmediately)
+ return addStatus(status)
},
retweet: (status) => {
// RetweetedStatuses are never shown immediately
- const retweetedStatus = addStatus(
- status.retweeted_status,
- false,
- false,
- )
-
- let retweet
- // If the retweeted status is already there, don't add the retweet
- // to the timeline.
- if (
- [...(timeline?.statuses.values() ?? [])].some((s) => {
- if (s.retweeted_status) {
- return (
- s.id === retweetedStatus.id ||
- s.retweeted_status.id === retweetedStatus.id
- )
- } else {
- return s.id === retweetedStatus.id
- }
- })
- ) {
- // Already have it visible (either as the original or another RT), don't add to timeline, don't show.
- retweet = addStatus(status, false, false)
- } else {
- retweet = addStatus(status, showImmediately)
- }
-
- retweet.retweeted_status = retweetedStatus
+ 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) {
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
},
}
- statuses.forEach((status) => {
+ return statuses.map((status) => {
const type = status.type
const processor = processors[type] ?? processors.default
- processor(status)
+ 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 }),
)
},
- fetchPinnedStatuses(userId) {
- return fetchPinnedStatuses({
- id: userId,
- credentials: useOAuthStore().token,
- }).then(({ data: statuses, timestamp }) =>
- this.addNewStatuses({
- statuses,
- timeline: 'userPinned',
- userId,
- showImmediately: true,
- noIdUpdate: true,
- 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
},
- // 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 ?? minBy(statuses, 'id').id ?? ''
- const maxNew = pagination.minId ?? maxBy(statuses, 'id').id ?? ''
-
- const newer = maxNew > timeline.maxId || timeline.maxId === ''
- const older = minNew < timeline.minId || timeline.minId === ''
-
- if (newer) {
- timeline.maxId = maxNew
- }
- if (older) {
- timeline.minId = minNew
- }
- },
- showNewStatuses(timelineName) {
- const timeline = this.timelines[timelineName]
-
- timeline.newStatusCount = 0
-
- timeline.visibleStatuses = new Map(
- [...timeline.statuses.entries()].slice(0, 50),
- )
- timeline.minVisibleId = last(timeline.visibleStatuses.keys())
- timeline.minId = ''
- timeline.maxId = ''
- this.updateTimelineExtremes(timeline, [...timeline.statuses.values()])
- },
- resetStatuses() {
- const emptyState = defaultState()
-
- Object.entries(emptyState).forEach(([key, value]) => {
- this[key] = value
- })
- },
- clearTimeline({ timeline, excludeUserId = false }) {
- const userId = excludeUserId ? this.timelines[timeline].userId : undefined
- this.timelines[timeline] = emptyTl(userId)
- },
- queueFlush({ timeline, id }) {
- this.timelines[timeline].flushMarker = id
- },
- queueFlushAll() {
- Object.keys(this.timelines).forEach((timeline) => {
- this.timelines[timeline].flushMarker = this.timelines[timeline].maxId
- })
- },
-
// 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
})
},
-
- // Misc
- removeUserStatuses({ timelineName, userId }) {
- const timeline = this.timelines[timelineName]
-
- timeline.statuses
- .values()
- .filter(({ user }) => user.id === userId)
- .forEach(({ id }) => {
- timeline.statuses.delete(id)
- timeline.visibleStatuses.delete(id)
- })
- timeline.minVisibleId =
- timeline.visibleStatuses.length > 0
- ? last(timeline.visibleStatuses).id
- : 0
- timeline.maxId =
- timeline.statuses.length > 0 ? first(timeline.statuses).id : 0
- },
setVirtualHeight({ statusId, height }) {
this.allStatuses.get(statusId).virtualHeight = height
},
updateStatusWithPoll({ id, poll }) {
const status = this.allStatuses.get(id)
status.poll = poll
},
- setLoading({ timeline, value }) {
- this.timelines[timeline].loading = value
- },
},
})
diff --git a/src/stores/timelines.js b/src/stores/timelines.js
new file mode 100644
index 0000000000..5f78440fd2
--- /dev/null
+++ b/src/stores/timelines.js
@@ -0,0 +1,233 @@
+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 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: 0,
+ minId: 0,
+ minVisibleId: 0,
+ loading: false,
+ flushMarker: 0,
+ fetcher: null,
+ }
+
+ const property = USER_TIMELINES.has(name) ? 'userId' : ARGUMENT_MAP[name]
+
+ if (property) {
+ result[property] = argument
+ }
+
+ return result
+}
+
+export const ARGUMENT_MAP = {
+ tag: 'tag',
+ list: 'listId',
+ bookmarks: 'bookmarkFolderId',
+ quotes: 'statusId',
+ search: 'query',
+}
+
+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)]))
+}
+
+const USER_TIMELINES = new Set(['user', 'userPinned', 'media', 'favorites'])
+//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 = USER_TIMELINES.has(name) ? 'userId' : 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 })
+ }
+ }
+
+ if (status.visibility === 'direct') {
+ if (timeline !== this.dms) {
+ this.addStatusesToTimeline('dms', null, { statuses, nested: true })
+ }
+ }
+ })
+ },
+
+ // Fetchers
+ startFetchingTimeline(timelineName, argument) {
+ const timeline = this[timelineName]
+ if (timeline.fetcher) return
+
+ if (
+ timelineName === 'favourites' &&
+ !useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
+ ) {
+ return
+ }
+
+ timeline.fetcher = timelineFetcher(
+ timeline,
+ argument,
+ ARGUMENT_MAP[timeline.name],
+ useOAuthStore().token,
+ )
+
+ timeline.fetcher.startFetching()
+ },
+ stopFetchingTimeline(timelineName) {
+ const timeline = this[timelineName]
+ timeline.fetcher?.stopFetching()
+ timeline.fetcher = null
+ },
+
+ // 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 || timeline.maxId === ''
+ const older = minNew < timeline.minId || timeline.minId === ''
+
+ if (newer) {
+ timeline.maxId = maxNew
+ }
+ if (older) {
+ 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)
+ },
+ 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 4aba007432..cf88bcd43c 100644
--- a/src/stores/users.js
+++ b/src/stores/users.js
@@ -1,753 +1,754 @@
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 { 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(),
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)
user.friendIds = [...new Set([...(user.friendIds || []), ...friendIds])]
},
saveFollowerIds({ id, followerIds }) {
const user = this.users.get(id)
user.followerIds = [...new Set([user.followerIds || [], ...followerIds])]
},
// 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 = []
}
},
clearFollowers(userId) {
const user = this.users.get(userId)
if (user) {
user.followerIds = []
}
},
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)
// implicit: if oldTimestamp is undefined this will still be false
if (oldTimestamp > timestamp) return existing // not overwriting old data with new
const { relationship: unused0, ...old } = existing
const { relationship: unused1, ...neu } = user
const newUser = { ...old, ...neu }
this.users.set(user.id, newUser)
this.usersByName.set(user.screen_name.toLowerCase(), newUser)
this.usersByURL.set(user.url.toLowerCase(), newUser)
this.timestamps.set(newUser, timestamp)
if (user.id === this.currentUser.id) {
this.currentUser = newUser
}
return this.users.get(user.id)
})
},
updateUserRelationship(relationships) {
relationships.forEach((relationship) => {
this.relationships[relationship.id] = relationship
})
},
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(({ data: relationships }) =>
this.updateUserRelationship(relationships),
)
}
},
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.updateUserRelationship([predictedRelationship])
this.addBlockId(id)
return blockUser({ id, expiresIn }).then(({ data: relationship }) => {
this.updateUserRelationship([relationship])
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: relationship }) =>
this.updateUserRelationship([relationship]),
)
},
removeUserFromFollowers(id) {
return removeUserFromFollowers({ id }).then((relationship) =>
this.updateUserRelationship([relationship]),
)
},
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.updateUserRelationship([relationship]),
)
},
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 }
this.updateUserRelationship([predictedRelationship])
this.addMuteId(id)
return muteUser({
id,
expiresIn,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) => {
this.updateUserRelationship([relationship])
this.addMuteId(id)
})
},
unmuteUser(id) {
const predictedRelationship = this.relationships[id] || { id }
predictedRelationship.muting = false
this.updateUserRelationship([predictedRelationship])
return unmuteUser({ id }).then(({ data: relationship }) =>
this.updateUserRelationship([relationship]),
)
},
hideReblogs(id) {
return followUser({
id,
reblogs: false,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
this.updateUserRelationship([relationship]),
)
},
showReblogs(id) {
return followUser({
id,
reblogs: true,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
this.updateUserRelationship([relationship]),
)
},
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(user.friendIds)
return fetchFriends({
id,
maxId,
credentials: useOAuthStore().token,
}).then(({ data: friends }) => {
this.addNewUsers(friends)
this.saveFriendIds({ id, friendIds: map(friends, 'id') })
return friends
})
},
fetchFollowers(id) {
const user = this.users.get(id)
const maxId = last(user.followerIds)
return fetchFollowers({
id,
maxId,
credentials: useOAuthStore().token,
}).then(({ data: followers }) => {
this.addNewUsers(followers)
this.saveFollowerIds({ id, followerIds: map(followers, 'id') })
return followers
})
},
subscribeUser(id) {
return followUser({
id,
notify: true,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
this.updateUserRelationship([relationship]),
)
},
unsubscribeUser(id) {
return followUser({
id,
notify: false,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
this.updateUserRelationship([relationship]),
)
},
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) {
dispatch('setWsToken', user.token)
// Initialize the shout socket.
dispatch('initializeSocket')
}
const startPolling = () => {
// Start getting fresh posts.
- dispatch('startFetchingTimeline', { timeline: 'friends' })
+ useTimelinesStore().startFetchingTimeline('friends')
// Start fetching notifications
dispatch('startFetchingNotifications')
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
// Start fetching chats
useChatsStore().startFetchingChats()
}
}
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
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()
}
// 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
Sun, Nov 1, 7:17 PM (11 h, 47 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1803280
Default Alt Text
(123 KB)

Event Timeline