Merge branch 'users-statuses-pinia' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2026-08-24 19:47:49 +03:00
commit a6f19bb213
43 changed files with 208 additions and 131 deletions

View file

@ -0,0 +1 @@
If user avatars next to mentions are enabled it will show empty placeholder avatar next to label while user is being fetched, to avoid jumps

View file

@ -0,0 +1 @@
Fix follower remove API call

View file

@ -1 +1,3 @@
Added an indicator next to instance's name showing WebSocket connection status (if enabled) Added an indicator next to instance's name showing WebSocket connection status (if enabled).
Timeline no longer show "loading" indicator at the bottom when fetching newer posts.
Added small indicator on top of timeline when new posts are being fetched

View file

@ -1,6 +1,5 @@
import { get, reduce } from 'lodash' import { get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia' import { mapState } from 'pinia'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue' import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue' import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
@ -13,6 +12,7 @@ import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js' import { useOAuthStore } from 'src/stores/oauth.js'
import { useStatusesStore } from 'src/stores/statuses.js' import { useStatusesStore } from 'src/stores/statuses.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js' import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js' import { WSConnectionStatus } from 'src/api/websocket.js'
@ -93,6 +93,7 @@ const conversation = {
default: false, default: false,
}, },
}, },
emits: ['update:virtualHeight'],
data() { data() {
return { return {
focused: null, focused: null,
@ -101,6 +102,7 @@ const conversation = {
inlineDivePosition: null, inlineDivePosition: null,
loadStatusError: null, loadStatusError: null,
unsuspendibleIds: new Set(), unsuspendibleIds: new Set(),
virtualHeight: 120,
} }
}, },
created() { created() {
@ -108,6 +110,9 @@ const conversation = {
this.fetchConversation() this.fetchConversation()
} }
}, },
mounted() {
this.updateVirtualHeight()
},
computed: { computed: {
status() { status() {
return useStatusesStore().allStatuses.get(this.statusId) return useStatusesStore().allStatuses.get(this.statusId)
@ -360,8 +365,8 @@ const conversation = {
return !!(this.expanded || this.isPage) return !!(this.expanded || this.isPage)
}, },
hiddenStyle() { hiddenStyle() {
const height = this.status?.virtualHeight || '120px' if (this.isExpanded) return {}
return this.virtualHidden ? { height } : {} return { height: this.virtualHeight + 'px' }
}, },
threadDisplayStatus() { threadDisplayStatus() {
return this.conversation.reduce((a, k) => { return this.conversation.reduce((a, k) => {
@ -388,11 +393,11 @@ const conversation = {
maybeFocused() { maybeFocused() {
return this.isExpanded ? this.focused : null return this.isExpanded ? this.focused : null
}, },
...mapPiniaState(useMergedConfigStore, ['mergedConfig']), ...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState({ ...mapState(useStreamingStore, {
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus, mastoUserSocketStatus: (state) => state.state,
}), }),
...mapPiniaState(useInterfaceStore, { ...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile', mobileLayout: (store) => store.layoutType === 'mobile',
}), }),
}, },
@ -426,10 +431,7 @@ const conversation = {
} }
}, },
virtualHidden() { virtualHidden() {
useStatusesStore().setVirtualHeight({ this.updateVirtualHeight()
statusId: this.statusId,
height: `${this.$el.clientHeight}px`,
})
}, },
}, },
methods: { methods: {
@ -618,6 +620,16 @@ const conversation = {
this.$router.push({ name: 'conversation', params: { id: data.id } }) this.$router.push({ name: 'conversation', params: { id: data.id } })
} }
}, },
updateVirtualHeight() {
this.$nextTick(() => {
this.virtualHeight = this.$refs.body.getBoundingClientRect().height
this.$emit('update:virtualHeight', {
id: this.status.id,
height: this.virtualHeight,
top: this.$el.clientTop,
})
})
},
}, },
} }

View file

@ -40,6 +40,7 @@
<div <div
v-if="isPage && !status" v-if="isPage && !status"
class="conversation-body" class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }" :class="{ 'panel-body': isExpanded }"
> >
<p v-if="!loadStatusError"> <p v-if="!loadStatusError">
@ -56,6 +57,7 @@
<div <div
v-else v-else
class="conversation-body" class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }" :class="{ 'panel-body': isExpanded }"
> >
<div <div
@ -116,6 +118,7 @@
@goto="setFocused" @goto="setFocused"
@dive="() => diveIntoStatus(status.id)" @dive="() => diveIntoStatus(status.id)"
@suspendable-state-change="onStatusSuspendStateChange" @suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/> />
<div <div
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1" v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
@ -174,6 +177,7 @@
@goto="setFocused" @goto="setFocused"
@dive="diveIntoStatus" @dive="diveIntoStatus"
@suspendable-state-change="onStatusSuspendStateChange" @suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/> />
</div> </div>
<div <div
@ -200,6 +204,7 @@
@goto="setFocused" @goto="setFocused"
@toggle-expanded="toggleExpanded" @toggle-expanded="toggleExpanded"
@suspendable-state-change="onStatusSuspendStateChange" @suspendable-state-change="onStatusSuspendStateChange"
@height-change="updateVirtualHeight"
/> />
</article> </article>
</div> </div>

View file

@ -135,9 +135,9 @@ export default {
this.showConfirmLogout() this.showConfirmLogout()
} }
}, },
doLogout() { async doLogout() {
await useUsersStore().logout()
this.$router.replace('/main/public') this.$router.replace('/main/public')
useUsersStore().logout()
this.hideConfirmLogout() this.hideConfirmLogout()
}, },
onSearchBarToggled(hidden) { onSearchBarToggled(hidden) {

View file

@ -1,9 +1,9 @@
import { mapActions, mapState as mapPiniaState } from 'pinia' import { mapActions, mapState } from 'pinia'
import { mapState } from 'vuex'
import { useAuthFlowStore } from 'src/stores/auth_flow.js' import { useAuthFlowStore } from 'src/stores/auth_flow.js'
import { useInstanceStore } from 'src/stores/instance.js' import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js' import { useOAuthStore } from 'src/stores/oauth.js'
import { useUsersStore } from 'src/stores/users.js'
import { getLoginUrl, getTokenWithCredentials } from 'src/api/oauth.js' import { getLoginUrl, getTokenWithCredentials } from 'src/api/oauth.js'
@ -18,12 +18,10 @@ const LoginForm = {
error: false, error: false,
}), }),
computed: { computed: {
...mapState({ ...mapState(useUsersStore, ['loggingIn']),
loggingIn: (state) => state.users.loggingIn, ...mapState(useOAuthStore, ['clientId', 'clientSecret']),
}), ...mapState(useInstanceStore, ['server', 'registrationOpen']),
...mapPiniaState(useOAuthStore, ['clientId', 'clientSecret']), ...mapState(useAuthFlowStore, {
...mapPiniaState(useInstanceStore, ['server', 'registrationOpen']),
...mapPiniaState(useAuthFlowStore, {
isTokenAuth: (store) => store.requiredToken, isTokenAuth: (store) => store.requiredToken,
isPasswordAuth: (store) => !store.requiredToken, isPasswordAuth: (store) => !store.requiredToken,
}), }),

View file

@ -29,7 +29,7 @@ const MentionLink = {
}, },
props: { props: {
url: { url: {
required: true, required: false,
type: String, type: String,
}, },
content: { content: {
@ -75,11 +75,11 @@ const MentionLink = {
}, },
computed: { computed: {
user() { user() {
return this.url && useUsersStore().findUserByUrl(this.url) return this.url ? useUsersStore().findUserByUrl(this.url) : null
}, },
isYou() { isYou() {
// FIXME why user !== currentUser??? if (!this.currentUser) return false
return this.user?.id === this.currentUser.id return this.user === this.currentUser
}, },
userName() { userName() {
return this.user && this.userNameFullUi.split('@')[0] return this.user && this.userNameFullUi.split('@')[0]

View file

@ -8,15 +8,20 @@
:href="url" :href="url"
class="original" class="original"
target="_blank" target="_blank"
v-html="content" ><!-- eslint-enable vue/no-v-html -->
/><!-- eslint-enable vue/no-v-html --> <UserAvatar
v-if="shouldShowAvatar"
class="mention-avatar"
:user-id="null"
/>
<span v-html="content" />
</a>
<UserPopover <UserPopover
v-else v-else
:user-id="user.id" :user-id="user.id"
:disabled="!shouldShowTooltip" :disabled="!shouldShowTooltip"
> >
<span <span
v-if="user"
class="new" class="new"
:style="style" :style="style"
:class="classnames" :class="classnames"

View file

@ -145,9 +145,9 @@ const MobileNav = {
} }
}, },
doLogout() { doLogout() {
this.$router.replace('/main/public')
useUsersStore().logout() useUsersStore().logout()
this.hideConfirmLogout() this.hideConfirmLogout()
this.$router.replace('/main/public')
}, },
markNotificationsAsSeen() { markNotificationsAsSeen() {
useNotificationsStore().markNotificationsAsSeen() useNotificationsStore().markNotificationsAsSeen()

View file

@ -1,5 +1,5 @@
import { mapState as mapPiniaState } from 'pinia' import { mapState } from 'pinia'
import { mapState } from 'vuex' import { mapState as mapVuexState } from 'vuex'
import BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue' import BookmarkFoldersMenuContent from 'src/components/bookmark_folders_menu/bookmark_folders_menu_content.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue' import Checkbox from 'src/components/checkbox/checkbox.vue'
@ -111,29 +111,29 @@ const NavPanel = {
}, },
}, },
computed: { computed: {
...mapPiniaState(useAnnouncementsStore, { ...mapState(useAnnouncementsStore, {
unreadAnnouncementCount: 'unreadAnnouncementCount', unreadAnnouncementCount: 'unreadAnnouncementCount',
supportsAnnouncements: (store) => store.supportsAnnouncements, supportsAnnouncements: (store) => store.supportsAnnouncements,
}), }),
...mapPiniaState(useInstanceCapabilitiesStore, [ ...mapState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable', 'pleromaChatMessagesAvailable',
'pleromaBookmarkFoldersAvailable', 'pleromaBookmarkFoldersAvailable',
'localBubble', 'localBubble',
]), ]),
...mapPiniaState(useInstanceStore, ['federating']), ...mapState(useInstanceStore, ['federating']),
...mapPiniaState(useInstanceStore, { ...mapState(useInstanceStore, {
privateMode: (store) => store.private, privateMode: (store) => store.private,
}), }),
...mapPiniaState(useSyncConfigStore, { ...mapState(useSyncConfigStore, {
collapsed: (store) => store.prefsStorage.simple.collapseNav, collapsed: (store) => store.prefsStorage.simple.collapseNav,
pinnedItems: (store) => pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems), new Set(store.prefsStorage.collections.pinnedNavItems),
}), }),
...mapPiniaState(useUsersStore, ['currentUser']), ...mapState(useUsersStore, ['currentUser']),
...mapState({ ...mapVuexState({
followRequestCount: (state) => state.api.followRequests.length, followRequestCount: (state) => state.api.followRequests.length,
}), }),
...mapPiniaState(useChatsStore, ['unreadChatsCount']), ...mapState(useChatsStore, ['unreadChatsCount']),
timelinesItems() { timelinesItems() {
return filterNavigation( return filterNavigation(
Object.entries({ ...TIMELINES }) Object.entries({ ...TIMELINES })

View file

@ -1,5 +1,5 @@
import { mapState as mapPiniaState } from 'pinia' import { mapState } from 'pinia'
import { mapState } from 'vuex' import { mapState as mapVuexState } from 'vuex'
import { import {
filterNavigation, filterNavigation,
@ -59,26 +59,26 @@ const NavPanel = {
getters() { getters() {
return this.$store.getters return this.$store.getters
}, },
...mapPiniaState(useListsStore, { ...mapState(useListsStore, {
lists: getListEntries, lists: getListEntries,
}), }),
...mapPiniaState(useAnnouncementsStore, { ...mapState(useAnnouncementsStore, {
supportsAnnouncements: (store) => store.supportsAnnouncements, supportsAnnouncements: (store) => store.supportsAnnouncements,
}), }),
...mapPiniaState(useBookmarkFoldersStore, { ...mapState(useBookmarkFoldersStore, {
bookmarks: getBookmarkFolderEntries, bookmarks: getBookmarkFolderEntries,
}), }),
...mapPiniaState(useSyncConfigStore, { ...mapState(useSyncConfigStore, {
pinnedItems: (store) => pinnedItems: (store) =>
new Set(store.prefsStorage.collections.pinnedNavItems), new Set(store.prefsStorage.collections.pinnedNavItems),
}), }),
...mapPiniaState(useInstanceStore, ['privateMode', 'federating']), ...mapState(useInstanceStore, ['privateMode', 'federating']),
...mapPiniaState(useInstanceCapabilitiesStore, [ ...mapState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable', 'pleromaChatMessagesAvailable',
'localBubble', 'localBubble',
]), ]),
...mapPiniaState(useUsersStore, ['currentUser']), ...mapState(useUsersStore, ['currentUser']),
...mapState({ ...mapVuexState({
followRequestCount: (state) => state.api.followRequests.length, followRequestCount: (state) => state.api.followRequests.length,
}), }),
pinnedList() { pinnedList() {

View file

@ -118,9 +118,6 @@ const Notification = {
useInstanceStore().restrictedNicknames, useInstanceStore().restrictedNicknames,
) )
}, },
getUser(notification) {
return this.$store.state.users.usersObject[notification.from_profile.id]
},
interacted() { interacted() {
this.$emit('interacted') this.$emit('interacted')
}, },

View file

@ -16,10 +16,10 @@ const oac = {
clientSecret, clientSecret,
instance: useInstanceStore().server, instance: useInstanceStore().server,
code: this.code, code: this.code,
}).then(({ data: result }) => { }).then(async ({ data: result }) => {
oauthStore.setToken(result.access_token) oauthStore.setToken(result.access_token)
useUsersStore().loginUser(result.access_token) await useUsersStore().loginUser(result.access_token)
this.$router.push({ name: 'friends' }) this.$router.push({ name: 'friends' })
}) })
} }

View file

@ -28,7 +28,7 @@ const QuickFilterSettings = {
path: 'replyVisibility', path: 'replyVisibility',
value: visibility, value: visibility,
}) })
useStatusesStore().queueFlushAll() useStatusesStore().requireReloadAll()
}, },
openTab(tab) { openTab(tab) {
useInterfaceStore().openSettingsModalTab(tab) useInterfaceStore().openSettingsModalTab(tab)

View file

@ -303,7 +303,7 @@
<!-- eslint-enable vue/no-v-html --> <!-- eslint-enable vue/no-v-html -->
</div> </div>
<div <div
v-if="serverValidationErrors.length" v-if="signUpErrors.length"
class="form-group" class="form-group"
> >
<div class="alert error"> <div class="alert error">

View file

@ -1,6 +1,6 @@
// eslint-disable-next-line no-unused // eslint-disable-next-line no-unused
import { mapState as mapPiniaState } from 'pinia' import { mapState } from 'pinia'
import { Fragment } from 'vue' import { Fragment } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
@ -60,7 +60,7 @@ export default {
return this.$slots.default().findIndex(isWanted) === this.activeIndex return this.$slots.default().findIndex(isWanted) === this.activeIndex
} }
}, },
...mapPiniaState(useInterfaceStore, { ...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile', mobileLayout: (store) => store.layoutType === 'mobile',
}), }),
}, },

View file

@ -36,7 +36,7 @@ const ClutterTab = {
// Updating nested properties // Updating nested properties
watch: { watch: {
replyVisibility() { replyVisibility() {
useStatusesStore().queueFlushAll() useStatusesStore().requireReloadAll()
}, },
}, },
} }

View file

@ -266,7 +266,7 @@ const FilteringTab = {
// Updating nested properties // Updating nested properties
watch: { watch: {
replyVisibility() { replyVisibility() {
useStatusesStore().queueFlushAll() useStatusesStore().requireReloadAll()
}, },
muteFiltersObject() { muteFiltersObject() {
this.muteFiltersDraftObject = cloneDeep( this.muteFiltersDraftObject = cloneDeep(

View file

@ -105,7 +105,6 @@ const Status = {
isPreview: Boolean, isPreview: Boolean,
noHeading: Boolean, noHeading: Boolean,
inlineExpanded: Boolean, inlineExpanded: Boolean,
showPinned: Boolean,
inProfile: Boolean, inProfile: Boolean,
inConversation: Boolean, inConversation: Boolean,
inQuote: Boolean, inQuote: Boolean,
@ -118,7 +117,13 @@ const Status = {
threadDisplayStatus: String, threadDisplayStatus: String,
}, },
emits: ['goto', 'dive', 'toggleExpanded', 'suspendableStateChange'], emits: [
'goto',
'dive',
'toggleExpanded',
'suspendableStateChange',
'heightChange',
],
data() { data() {
return { return {
replying: false, replying: false,
@ -189,7 +194,7 @@ const Status = {
) )
// User referenced in post might not be yet present in store // User referenced in post might not be yet present in store
// since their data is not included in status data // since their data is not included in status data, just the id
return user?.statusnet_profile_url return user?.statusnet_profile_url
} }
}, },
@ -370,7 +375,7 @@ const Status = {
}, },
replyToName() { replyToName() {
if (this.mainStatus.in_reply_to_screen_name) { if (this.mainStatus.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name return this.mainStatus.in_reply_to_screen_name
} else { } else {
const user = useUsersStore().findUser( const user = useUsersStore().findUser(
this.mainStatus.in_reply_to_user_id, this.mainStatus.in_reply_to_user_id,
@ -387,7 +392,7 @@ const Status = {
return uniqBy(combinedUsers, 'id') return uniqBy(combinedUsers, 'id')
}, },
tags() { tags() {
return this.status.tags return [...this.status.tags]
.filter((tagObj) => Object.hasOwn(tagObj, 'name')) .filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name) .map((tagObj) => tagObj.name)
.join(' ') .join(' ')
@ -539,6 +544,7 @@ const Status = {
this.headTailLinks = headTailLinks this.headTailLinks = headTailLinks
}, },
toggleThreadDisplay() { toggleThreadDisplay() {
// FIXME
this.controlledToggleThreadDisplay() this.controlledToggleThreadDisplay()
}, },
scrollIfFocused(focused) { scrollIfFocused(focused) {
@ -557,8 +563,22 @@ const Status = {
} }
} }
}, },
onTransitionEnd() {
this.$nextTick(() => {
this.$emit('heightChange')
})
},
}, },
watch: { watch: {
status: {
deep: true,
handler() {
this.$emit('heightChange')
},
},
replying() {
this.$emit('heightChange')
},
focused: function (id) { focused: function (id) {
this.scrollIfFocused(id) this.scrollIfFocused(id)
}, },
@ -584,6 +604,7 @@ const Status = {
}, },
isSuspendable: function (suspend) { isSuspendable: function (suspend) {
this.$emit('suspendableStateChange', { id: this.status.id, suspend }) this.$emit('suspendableStateChange', { id: this.status.id, suspend })
this.$emit('heightChange')
}, },
}, },
} }

View file

@ -169,7 +169,7 @@
<span class="heading-right"> <span class="heading-right">
<span <span
v-if="showPinned" v-if="mainStatus.pinned"
class="pin" class="pin"
> >
<FAIcon <FAIcon
@ -454,7 +454,10 @@
</StatusPopover> </StatusPopover>
</div> </div>
<transition name="fade"> <Transition
@after-leave="onTransitionEnd"
name="fade"
>
<div <div
v-if="shouldDisplayFavsAndRepeats" v-if="shouldDisplayFavsAndRepeats"
class="favs-repeated-users" class="favs-repeated-users"
@ -502,7 +505,7 @@
</div> </div>
</div> </div>
</div> </div>
</transition> </Transition>
<EmojiReactions <EmojiReactions
v-if="(mergedConfig.emojiReactionsOnTimeline || focused) && (!noHeading && !isPreview)" v-if="(mergedConfig.emojiReactionsOnTimeline || focused) && (!noHeading && !isPreview)"

View file

@ -1,14 +1,14 @@
import { mapState } from 'pinia' import { mapState } from 'pinia'
import Modal from 'src/components/modal/modal.vue' import Modal from 'src/components/modal/modal.vue'
import StatusContent from 'src/components/status_content/status_content.vue' import Status from 'src/components/status/status.vue'
import { useStatusHistoryStore } from 'src/stores/statusHistory.js' import { useStatusHistoryStore } from 'src/stores/statusHistory.js'
const StatusHistoryModal = { const StatusHistoryModal = {
components: { components: {
Modal, Modal,
StatusContent, Status,
}, },
data() { data() {
return { return {

View file

@ -15,10 +15,10 @@
v-if="historyCount > 0" v-if="historyCount > 0"
class="history-body" class="history-body"
> >
<StatusContent <Status
v-for="status in history" v-for="status in history"
:key="status.id" :key="status.id"
:status="status" :statusoid="status"
:is-preview="true" :is-preview="true"
class="conversation-status status-fadein panel-body" class="conversation-status status-fadein panel-body"
/> />

View file

@ -101,6 +101,10 @@ export default {
classesTab.push('active') classesTab.push('active')
classesWrapper.push('active') classesWrapper.push('active')
} }
if (props.disabled) {
classesTab.push('disabled')
classesWrapper.push('disabled')
}
if (props.image) { if (props.image) {
return ( return (
<div class={classesWrapper.join(' ')}> <div class={classesWrapper.join(' ')}>

View file

@ -30,7 +30,7 @@ const ThreadTree = {
totalReplyCount: Object, totalReplyCount: Object,
totalReplyDepth: Object, totalReplyDepth: Object,
}, },
emits: ['suspendableStateChange', 'goto', 'dive'], emits: ['suspendableStateChange', 'goto', 'dive', 'heightChange'],
computed: { computed: {
currentReplies() { currentReplies() {
return this.getReplies(this.statusId).map(({ id }) => id) return this.getReplies(this.statusId).map(({ id }) => id)

View file

@ -22,6 +22,7 @@
@goto="$emit('goto', statusId)" @goto="$emit('goto', statusId)"
@toggle-expanded="toggleExpanded" @toggle-expanded="toggleExpanded"
@suspendable-state-change="e => $emit('suspendableStateChange', e)" @suspendable-state-change="e => $emit('suspendableStateChange', e)"
@height-change="e => $emit('heightChange', e)"
/> />
<div <div
v-if="currentReplies.length > 0 && threadShowing" v-if="currentReplies.length > 0 && threadShowing"
@ -55,6 +56,7 @@
@goto="(e) => $emit('goto', e)" @goto="(e) => $emit('goto', e)"
@dive="(e) => $emit('dive', e)" @dive="(e) => $emit('dive', e)"
@suspendable-state-change="e => $emit('suspendableStateChange', e)" @suspendable-state-change="e => $emit('suspendableStateChange', e)"
@height-change="e => $emit('heightChange', e)"
/> />
</div> </div>
<div <div

View file

@ -27,7 +27,6 @@ library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = { const Timeline = {
props: { props: {
timelineRef: Object, timelineRef: Object,
count: Number,
footerSlipgate: Object, // reference to an element where we should put our footer footerSlipgate: Object, // reference to an element where we should put our footer
embedded: Boolean, embedded: Boolean,
inProfile: Boolean, inProfile: Boolean,
@ -59,21 +58,24 @@ const Timeline = {
.map((id) => useStatusesStore().allStatuses.get(id)) .map((id) => useStatusesStore().allStatuses.get(id))
.filter(({ pinned }) => (this.skipPinned ? !pinned : true)) .filter(({ pinned }) => (this.skipPinned ? !pinned : true))
}, },
count() {
return this.timeline.order.length
},
newStatusCount() { newStatusCount() {
return this.timeline.newStatusCount return this.timeline.newStatusCount
}, },
showLoadButton() { showLoadButton() {
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0 return this.timeline.newStatusCount > 0 || this.timeline.reloadNeeded
}, },
loadButtonString() { loadButtonString() {
if (this.timeline.flushMarker !== 0) { if (this.timeline.reloadNeeded) {
return this.$t('timeline.reload') return this.$t('timeline.reload')
} else { } else {
return `${this.$t('timeline.show_new')} (${this.newStatusCount})` return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
} }
}, },
mobileLoadButtonString() { mobileLoadButtonString() {
if (this.timeline.flushMarker !== 0) { if (this.timeline.reloadNeeded) {
return '+' return '+'
} else { } else {
return this.newStatusCount > 99 ? '∞' : this.newStatusCount return this.newStatusCount > 99 ? '∞' : this.newStatusCount
@ -99,6 +101,7 @@ const Timeline = {
} }
}, },
statusesToDisplay() { statusesToDisplay() {
if (!this.virtualScrollingEnabled) return this.visibleStatusIds
const amount = this.timeline.visibleStatusIds.size const amount = this.timeline.visibleStatusIds.size
const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80)) const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
const min = Math.max(0, this.virtualScrollIndex - statusesPerSide) const min = Math.max(0, this.virtualScrollIndex - statusesPerSide)
@ -167,9 +170,8 @@ const Timeline = {
if (e.key === '.') this.showNewStatuses() if (e.key === '.') this.showNewStatuses()
}, },
showNewStatuses() { showNewStatuses() {
if (this.timeline.flushMarker !== 0) { if (this.timeline.reloadNeeded) {
useTimelinesStore().clearTimeline(this.timelineRef.name) useTimelinesStore().clearTimeline(this.timelineRef.name)
useTimelinesStore().queueFlush(this.timelineRef.name, '')
this.fetchOlderStatuses() this.fetchOlderStatuses()
} else { } else {
this.blockClicksTemporarily() this.blockClicksTemporarily()
@ -190,13 +192,12 @@ const Timeline = {
if (!this.virtualScrollingEnabled) return if (!this.virtualScrollingEnabled) return
const statuses = this.$refs.timeline.children const statuses = this.$refs.timeline.children
if (statuses.length === 0) return
const cappedScrollIndex = Math.max( const cappedScrollIndex = Math.max(
0, 0,
Math.min(this.virtualScrollIndex, statuses.length - 1), Math.min(this.virtualScrollIndex, statuses.length - 1),
) )
if (statuses.length === 0) return
const height = Math.max(document.body.offsetHeight, window.pageYOffset) const height = Math.max(document.body.offsetHeight, window.pageYOffset)
const centerOfScreen = window.pageYOffset + window.innerHeight * 0.5 const centerOfScreen = window.pageYOffset + window.innerHeight * 0.5
@ -234,11 +235,11 @@ const Timeline = {
this.virtualScrollIndex = approxIndex this.virtualScrollIndex = approxIndex
}, },
scrollLoad() { scrollLoad() {
// TODO simplify this logic
const bodyBRect = document.body.getBoundingClientRect() const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y) const height = Math.max(bodyBRect.height, -bodyBRect.y)
if ( if (
!this.timeline.fetcher.loading.value && !this.timeline.fetcher.loadingOlder.value &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750 window.innerHeight + window.pageYOffset >= height - 750
) { ) {
this.fetchOlderStatuses() this.fetchOlderStatuses()

View file

@ -1,5 +1,6 @@
<template> <template>
<div :class="['Timeline', classes.root]"> <!-- there is a brief moment during logout when old timeline gets forcibly deactivated -->
<div v-if="timeline.fetcher" :class="['Timeline', classes.root]">
<div <div
v-if="!embedded" v-if="!embedded"
:class="classes.header" :class="classes.header"
@ -8,6 +9,16 @@
v-if="!embedded" v-if="!embedded"
:timeline-name="timelineRef.name" :timeline-name="timelineRef.name"
/> />
<div
v-if="timeline.fetcher.loadingNewer"
class="loadingIndicator"
>
<FAIcon
fixed-width
icon="circle-notch"
spin
/>
</div>
<ScrollTopButton /> <ScrollTopButton />
<template v-if="mobileLayout"> <template v-if="mobileLayout">
<div <div
@ -84,7 +95,7 @@
/> />
</div> </div>
</div> </div>
<div :class="classes.footer"> <div v-if="!embedded || footerSlipgate" :class="classes.footer">
<teleport <teleport
:to="footerSlipgate" :to="footerSlipgate"
:disabled="!embedded || !footerSlipgate" :disabled="!embedded || !footerSlipgate"
@ -102,7 +113,7 @@
{{ $t('timeline.no_more_statuses') }} {{ $t('timeline.no_more_statuses') }}
</div> </div>
<button <button
v-else-if="!timeline.fetcher.loading" v-else-if="!timeline.fetcher.loadingOlder"
class="button-unstyled -link" class="button-unstyled -link"
@click.prevent="fetchOlderStatuses()" @click.prevent="fetchOlderStatuses()"
> >

View file

@ -12,7 +12,7 @@ const UserAvatar = {
props: { props: {
// UserID of a user to show avatar of // UserID of a user to show avatar of
userId: { userId: {
required: true, required: false, // You can pass null to just render a placeholder
type: String, type: String,
}, },
// Use less space and use alternative roundness // Use less space and use alternative roundness

View file

@ -70,6 +70,7 @@
&.-placeholder { &.-placeholder {
background-color: var(--background); background-color: var(--background);
border: 1px solid var(--border)
} }
} }

View file

@ -100,7 +100,6 @@ const UserProfile = {
}, },
load(userNameOrId) { load(userNameOrId) {
const loadById = (userId) => { const loadById = (userId) => {
console.log('LOAD', userId)
this.userId = userId this.userId = userId
} }
@ -139,7 +138,6 @@ const UserProfile = {
} }
}, },
switchUser(userNameOrId) { switchUser(userNameOrId) {
console.log('USER SWITCH')
this.load(userNameOrId) this.load(userNameOrId)
}, },
onTabSwitch(tab) { onTabSwitch(tab) {

View file

@ -23,18 +23,14 @@
key="statuses" key="statuses"
class="statuses" class="statuses"
:label="$t('user_card.statuses')" :label="$t('user_card.statuses')"
:count="user.statuses_count"
:title="$t('user_profile.timeline_title')" :title="$t('user_profile.timeline_title')"
> >
<!--
<Timeline <Timeline
key="statuses" key="statuses"
:timeline-ref="{ name: 'userPinned', argument: userId }" :timeline-ref="{ name: 'userPinned', argument: userId }"
embedded embedded
in-profile in-profile
:footer-slipgate="footerRef"
/> />
-->
<Timeline <Timeline
:timeline-ref="{ name: 'user', argument: userId }" :timeline-ref="{ name: 'user', argument: userId }"
embedded embedded
@ -81,7 +77,6 @@
<Timeline <Timeline
key="media" key="media"
:label="$t('user_card.media')" :label="$t('user_card.media')"
:disabled="media.visibleStatusIds.size === 0"
:title="$t('user_card.media')" :title="$t('user_card.media')"
:timeline-ref="{ name: 'media', argument: userId }" :timeline-ref="{ name: 'media', argument: userId }"
embedded embedded

View file

@ -47,10 +47,12 @@ const UserProfileAdminView = {
}, },
methods: { methods: {
fetchStatuses(page) { fetchStatuses(page) {
return useAdminSettingsStore().fetchStatuses({ return useAdminSettingsStore()
.fetchStatuses({
...this.fetchOptions, ...this.fetchOptions,
page, page,
}) })
.then(({ items }) => items)
}, },
}, },
components: { components: {

View file

@ -259,7 +259,7 @@ export const parseStatus = (data) => {
output.raw_html = data.content output.raw_html = data.content
output.emojis = data.emojis output.emojis = data.emojis
output.tags = data.tags output.tags = new Set(data.tags ?? [])
output.edited_at = data.edited_at output.edited_at = data.edited_at

View file

@ -63,8 +63,8 @@ export const useAuthFlowStore = defineStore('authFlow', {
this.settings = {} this.settings = {}
}, },
async login({ access_token: accessToken }) { async login({ access_token: accessToken }) {
useOAuthStore().setToken(accessToken) await useOAuthStore().setToken(accessToken)
useUsersStore().loginUser(accessToken, { root: true }) await useUsersStore().loginUser(accessToken, { root: true })
this.resetState() this.resetState()
}, },
}, },

View file

@ -18,12 +18,17 @@ const REPLY_VISIBILITY_TIMELINES = new Set([
]) ])
const timelineFetcher = (timeline, argument, credentials) => { const timelineFetcher = (timeline, argument, credentials) => {
const loading = ref(false) const loadingNewer = ref(false)
const loadingOlder = ref(false)
const bottomedOut = ref(false) const bottomedOut = ref(false)
const interval = ref(null) const interval = ref(null)
const fetchAndUpdate = ({ older = false, showImmediately = false } = {}) => { const fetchAndUpdate = ({ older = false, showImmediately = false } = {}) => {
loading.value = true if (older) {
loadingOlder.value = true
} else {
loadingNewer.value = true
}
const { hideMutedPosts, replyVisibility } = const { hideMutedPosts, replyVisibility } =
useMergedConfigStore().mergedConfig useMergedConfigStore().mergedConfig
@ -47,10 +52,11 @@ const timelineFetcher = (timeline, argument, credentials) => {
const numStatusesBeforeFetch = timeline.statusIds.size const numStatusesBeforeFetch = timeline.statusIds.size
if (bottomedOut.value) return
return fetchTimeline(args) return fetchTimeline(args)
.then(({ data: statuses, pagination, timestamp }) => { .then(({ data: statuses, pagination, timestamp }) => {
if (!older && statuses.length >= 20 && numStatusesBeforeFetch > 0) { if (!older && statuses.length >= 20 && numStatusesBeforeFetch > 0) {
useTimelinesStore().queueFlush(timeline.name, timeline.maxId) useTimelinesStore().requireReload(timeline.name)
} }
if (older && statuses.length === 0) { if (older && statuses.length === 0) {
@ -84,7 +90,11 @@ const timelineFetcher = (timeline, argument, credentials) => {
}) })
}) })
.finally(() => { .finally(() => {
loading.value = false if (older) {
loadingOlder.value = false
} else {
loadingNewer.value = false
}
}) })
} }
@ -107,7 +117,8 @@ const timelineFetcher = (timeline, argument, credentials) => {
startFetching, startFetching,
stopFetching, stopFetching,
fetchOlder: () => fetchAndUpdate({ showImmediately: true, older: true }), fetchOlder: () => fetchAndUpdate({ showImmediately: true, older: true }),
loading, loadingOlder,
loadingNewer,
bottomedOut, bottomedOut,
} }
} }

View file

@ -53,7 +53,7 @@ export const useOAuthStore = defineStore('oauth', {
this.userToken = token this.userToken = token
}, },
clearToken() { clearToken() {
this.userToken = false this.userToken = null
}, },
async createApp() { async createApp() {
const instance = useInstanceStore().server const instance = useInstanceStore().server

View file

@ -540,10 +540,5 @@ export const useStatusesStore = defineStore('statuses', {
}) })
return removed return removed
}, },
// Misc
setVirtualHeight({ statusId, height }) {
this.allStatuses.get(statusId).virtualHeight = height
},
}, },
}) })

View file

@ -180,7 +180,6 @@ export const useStreamingStore = defineStore('streaming', {
case 'delete': case 'delete':
return [data.id] return [data.id]
default: default:
console.log('UNKNOWN', eventName, eventStream, data)
return data return data
} }
})() })()

View file

@ -16,7 +16,7 @@ const emptyTl = (name, argument = null) => {
maxId: '', maxId: '',
minId: '', minId: '',
streaming: false, streaming: false,
flushMarker: 0, reloadNeeded: false,
fetcher: null, fetcher: null,
socket: null, socket: null,
} }
@ -158,7 +158,7 @@ export const useTimelinesStore = defineStore('timelines', {
timeline.newStatusCount = 0 timeline.newStatusCount = 0
timeline.maxId = '' timeline.maxId = ''
timeline.minId = '' timeline.minId = ''
timeline.flushMarker = 0 timeline.reloadNeeded = false
}, },
activatePersistents() { activatePersistents() {
TIMELINES.forEach((name) => { TIMELINES.forEach((name) => {
@ -258,14 +258,24 @@ export const useTimelinesStore = defineStore('timelines', {
timeline.fetcher.startFetching() timeline.fetcher.startFetching()
}, },
stopFetchingTimeline(timelineName, reason) { stopFetchingTimeline(timelineName, reason) {
const timeline = this[timelineName]
if (timeline.fetcher === null) {
console.debug(
'[Timelines] Already inactive timeline',
timelineName,
'Reason:',
reason,
)
return
} else {
timeline.fetcher.stopFetching()
console.debug( console.debug(
'[Timelines] Stopped fetching timeline', '[Timelines] Stopped fetching timeline',
timelineName, timelineName,
'Reason:', 'Reason:',
reason, reason,
) )
const timeline = this[timelineName] }
timeline.fetcher.stopFetching()
}, },
// Queues & Timeline manip // Queues & Timeline manip
@ -295,12 +305,12 @@ export const useTimelinesStore = defineStore('timelines', {
syncOrder(timeline) { syncOrder(timeline) {
timeline.order = timeline.order.filter((id) => timeline.statusIds.has(id)) timeline.order = timeline.order.filter((id) => timeline.statusIds.has(id))
}, },
queueFlush(timeline, id) { requireReload(timeline, id) {
this[timeline].flushMarker = id this[timeline].reloadNeeded = true
}, },
queueFlushAll() { requireReloadAll() {
Object.keys(this).forEach((timeline) => { Object.keys(this).forEach((timeline) => {
this[timeline].flushMarker = this[timeline].maxId this[timeline].reloadNeeded = true
}) })
}, },

View file

@ -354,11 +354,13 @@ describe('RichContent', () => {
'<span class="MentionLink mention-link">', '<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">', '<a href="lol" class="original" target="_blank">',
'<span>', '<span>',
'<span>',
'https://</span>', 'https://</span>',
'<span>', '<span>',
'lol.tld/</span>', 'lol.tld/</span>',
'<span>', '<span>',
'</span>', '</span>',
'</span>',
'</a>', '</a>',
'</span>', '</span>',
'</span>', '</span>',
@ -418,21 +420,25 @@ describe('RichContent', () => {
'<span class="MentionLink mention-link">', '<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">', '<a href="lol" class="original" target="_blank">',
'<span>', '<span>',
'<span>',
'https://</span>', 'https://</span>',
'<span>', '<span>',
'lol.tld/</span>', 'lol.tld/</span>',
'<span>', '<span>',
'</span>', '</span>',
'</span>',
'</a>', '</a>',
'</span>', '</span>',
'<span class="MentionLink mention-link">', '<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">', '<a href="lol" class="original" target="_blank">',
'<span>', '<span>',
'<span>',
'https://</span>', 'https://</span>',
'<span>', '<span>',
'lol.tld/</span>', 'lol.tld/</span>',
'<span>', '<span>',
'</span>', '</span>',
'</span>',
'</a>', '</a>',
'</span>', '</span>',
'</span>', '</span>',

View file

@ -21,7 +21,6 @@ describe('Timelines store', () => {
const sub = vi.fn() const sub = vi.fn()
useStreamingStore().addSubscriber = sub useStreamingStore().addSubscriber = sub
console.log(store.activate)
store.activate('friends', undefined, true) store.activate('friends', undefined, true)
expect(sub).to.have.been.called expect(sub).to.have.been.called
@ -34,7 +33,6 @@ describe('Timelines store', () => {
const sub = vi.fn() const sub = vi.fn()
useStreamingStore().addSubscriber = sub useStreamingStore().addSubscriber = sub
console.log(store.activate)
store.activate('user', '1') store.activate('user', '1')
expect(sub).to.not.have.been.called expect(sub).to.not.have.been.called

View file

@ -1095,7 +1095,6 @@ describe('Users store', () => {
const store = useUsersStore() const store = useUsersStore()
const { storeAction, apiUrl } = actionKeys(action) const { storeAction, apiUrl } = actionKeys(action)
await store[storeAction](userId) await store[storeAction](userId)
console.log(apiUrl)
expect(mockFetch).to.have.been.calledWith( expect(mockFetch).to.have.been.calledWith(
USER_API[apiUrl](userId), USER_API[apiUrl](userId),