Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85827063
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Authored By
Unknown
Size
116 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/changelog.d/akkoma-dms.fix b/changelog.d/akkoma-dms.fix
new file mode 100644
index 0000000000..fdc5662539
--- /dev/null
+++ b/changelog.d/akkoma-dms.fix
@@ -0,0 +1 @@
+Removed DMs timeline from persistent timelines so that Akkoma users don't get continious "404 - Not Implemented" banner all the time. Proper fix will be in the works shortly.
diff --git a/changelog.d/drafts-th.fix b/changelog.d/drafts-th.fix
new file mode 100644
index 0000000000..c2652f770d
--- /dev/null
+++ b/changelog.d/drafts-th.fix
@@ -0,0 +1 @@
+Fixed drafts being triggered immideately even if user didn't write or change anything yet
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 7869e5df47..74ddc7a6ed 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,388 +1,457 @@
import { last } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
nextTick,
provide,
ref,
toRefs,
useTemplateRef,
watch,
} from '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 RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useClientRectSize } from 'src/composables/useClientRectSize.js'
import { useConversation } from 'src/composables/useConversation.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useTreeConversationTopology } from 'src/composables/useTreeConversationTopology.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
export default {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
isPage: Boolean,
// Whether to engage virtual scrolling scroll compensation
scrollCompensation: {
type: Boolean,
default: true,
},
+ // For embedded conversation, what is its top offset in timeline
+ // (used for scrolling)
+ top: {
+ type: Number,
+ default: 0,
+ },
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
RichContent,
},
emits: ['heightChange', 'suspendableStateChange', 'expanded', 'collapsed'],
setup(props, { emit }) {
const scroller = inject('bodyScrollPosition')
const { statusId } = toRefs(props)
// # Main Configuration / global state
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const displayStyle = computed(() => mergedConfig.value.conversationDisplay)
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
// # Conversation Expansion
const expanded = ref(false)
const { isPage } = toRefs(props)
const isExpanded = computed(() => !!(expanded.value || isPage.value))
const toggleExpanded = async () => {
const newVal = !expanded.value
if (newVal) {
virtualScrollingEnabled.value = newVal
await nextTick()
expanded.value = newVal
} else {
expanded.value = newVal
await nextTick()
virtualScrollingEnabled.value = newVal
}
}
provide('isExpanded', isExpanded)
provide('isPage', isPage)
provide('expandable', true)
watch(expanded, (val) => (val ? emit('expanded') : emit('collapsed')), {
flush: 'post',
})
// # Main things
const {
focusedId,
conversationId,
setFocused,
currentStatus,
conversation,
replies,
getReplies,
fetchConversation,
loadError,
} = useConversation(statusId, isExpanded)
const conversationLite = computed(() =>
conversation.value.map(({ id }) => ({ id })),
)
provide('focusedId', focusedId)
provide('conversation', conversation)
provide('replies', replies)
// Component created
if (isPage.value) {
fetchConversation()
}
// # Misc UI things
const firstStatus = computed(() => conversation.value[0])
const lastStatus = computed(() => last(conversation.value))
const getStatusClasses = (statusId, ancestor) => {
const result = {
'-first': statusId === firstStatus.value?.id,
'-last': statusId === lastStatus.value?.id,
}
if (ancestor) {
result['-ancestor'] = true
result['-fade'] = mergedConfig.value.conversationTreeFadeAncestors
}
return result
}
// # External virtual scrolling
const unsuspendableIds = ref(new Set())
const suspendable = computed(
() => !isExpanded.value && unsuspendableIds.value.size === 0,
)
const rootElement = useTemplateRef('root')
const rootElementMargin = ref(null)
const { height } = useClientRectSize(rootElement)
watch(height, (value) => {
const rootCss = window.getComputedStyle(rootElement.value)
const rootMarginString = rootCss.getPropertyValue('margin-top')
const rootMargin = Number.parseInt(rootMarginString.slice(0, -2), 10)
rootElementMargin.value = rootMargin
const border = isExpanded.value ? 2 : 1
emit('heightChange', {
id: statusId.value,
height: value + border,
element: rootElement,
})
})
watch(suspendable, (value) =>
emit('suspendableStateChange', {
suspendable: value,
id: statusId.value,
}),
)
// # Internal virtual scrolling
const virtualScrollingEnabled = ref(isExpanded.value)
// Placeholder heights.
const { fontSize, navbarSize, panelHeaderSize } = useInterfaceSizes()
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) => normalStatusHeight
const offset = computed(() => {
// The fontsize after navbar is the little gap between navbar and content
if (isPage.value) {
return navbarSize.value + fontSize.value + panelHeaderSize.value
} else if (expanded.value) {
return (
navbarSize.value +
fontSize.value +
panelHeaderSize.value * 2 +
rootElementMargin.value
)
} else {
return navbarSize.value + fontSize.value
}
})
const { scrollCompensation } = toRefs(props)
// # Linear style stuff
const isLinearView = computed(() => displayStyle.value !== 'tree')
const linearElement = useTemplateRef('linear')
const linearScrollCompensation = computed(
() =>
virtualScrollingEnabled.value &&
isLinearView.value &&
scrollCompensation.value,
)
const {
heightChart: heightChartLinear,
+ heightChartFull: heightChartFullLinear,
changeSuspendState: changeSuspendStateLinear,
updateVirtualHeight: updateVirtualHeightLinear,
reset: resetLinearScrollVirtualization,
- scrollTo: linearScrollTo,
} = useVirtualScrolling({
name: 'Linear',
enabled: linearScrollCompensation,
list: conversationLite,
body: linearElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: linearScrollCompensation,
getPlaceholderHeight,
})
const changeSuspendStateLinearLocal = (e) => {
changeSuspendStateLinear(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
// # Tree style stuff
const isTreeView = computed(() => displayStyle.value === 'tree')
const {
topLevel,
currentAncestors,
threadDisplay,
setThreadDisplayRecursively,
showThreadRecursively,
resetThreadDisplay,
totalReplyCount,
totalReplyDepth,
} = useTreeConversationTopology(conversation, replies, focusedId)
provide('threadDisplay', threadDisplay)
provide('totalReplyCount', totalReplyCount)
provide('totalReplyDepth', totalReplyDepth)
watch(
isExpanded,
(value) => {
if (!value) resetThreadDisplay()
},
{ flush: 'post' },
)
const toggleThreadDisplay = (id) => {
const current = threadDisplay.value.get(id)
const next = current === 'hidden' ? 'showing' : 'hidden'
setThreadDisplayRecursively(id, next)
}
const currentAncestorsLite = computed(() =>
currentAncestors.value.map(({ id }) => ({ id })),
)
const ancestorsElement = useTemplateRef('ancestors')
const treeScrollCompensation = computed(
() =>
virtualScrollingEnabled.value &&
isTreeView.value &&
scrollCompensation.value,
)
const {
heightChart: heightChartAncestors,
changeSuspendState: changeSuspendStateAncestors,
updateVirtualHeight: updateVirtualHeightAncestors,
reset: resetTreeScrollVirtualization,
} = useVirtualScrolling({
name: 'Ancestors',
enabled: treeScrollCompensation,
list: currentAncestorsLite,
body: ancestorsElement,
offset,
scrollPositionInstance: scroller,
scrollCompensation: treeScrollCompensation,
collapseMode: 'height',
getPlaceholderHeight,
})
const changeSuspendStateAncestorsLocal = (e) => {
changeSuspendStateAncestors(e)
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
const changeSuspendStateCurrentLevelLocal = (e) => {
const { id, suspendable } = e
if (suspendable) {
unsuspendableIds.value.delete(id)
} else {
unsuspendableIds.value.add(id)
}
}
watch(conversationId, (neu, old) => {
resetLinearScrollVirtualization()
resetTreeScrollVirtualization()
})
const treeViewIsSimple = computed(
() => !mergedConfig.value.conversationTreeAdvanced,
)
const shouldShowAllConversationButton = computed(
() => currentAncestors.value.length > 0 && topLevel.value.length > 1,
)
const shouldShowAncestors = computed(
() => isExpanded.value && heightChartAncestors.value.length > 0,
)
// # Scrolling
- const scrollTo = (ids) => {
- if (isLinearView.value) {
- return linearScrollTo(ids)
- }
- }
const focusTopLevel = () => setFocused(currentAncestors.value[0].id)
+ const scrollOffset = computed(() => {
+ const { vHeight } = scroller
+ const screenMiddle = vHeight.value / 2
+
+ const panelHeadersAmount = isPage.value ? 1 : 2
+ return (
+ navbarSize.value +
+ panelHeaderSize.value * panelHeadersAmount +
+ rootTop.value -
+ screenMiddle
+ )
+ })
+ const { top: rootTop } = toRefs(props)
watch(focusedId, async (neu, old) => {
- // Ignoring initial update (null -> id) since that is handled by scroll compensation
- if (old && neu) scrollTo(new Set([neu]))
+ // tree view has its own weirdness and doesn't require scroll
+ if (!isLinearView.value) return
+
+ setTimeout(() => {
+ const element = heightChartFullLinear.value.find(({ id }) => id === neu)
+ if (!element) return
+ const { top, height } = element
+ const elementMiddle = top + height / 2
+
+ const targetScrollPosition = Math.max(
+ elementMiddle + scrollOffset.value,
+ 0,
+ )
+
+ scroller.scrollToPriority({
+ top: targetScrollPosition,
+ behavior: 'smooth',
+ })
+ }, 5) // let other scroll stuff happen first (scroll compensation etc)
+ })
+ const treeElement = useTemplateRef('tree')
+ watch(isExpanded, async (neu) => {
+ setTimeout(() => {
+ if (neu) {
+ if (isLinearView.value) {
+ return // handled in focus watcher
+ } else {
+ const wrapperElement = treeElement.value.$el
+ if (!wrapperElement) return
+ const top = wrapperElement.offsetTop
+ const element = wrapperElement.querySelector('.Status')
+ if (!element) return
+ const height = element.offsetHeight
+
+ const elementMiddle = top + height / 2
+
+ const targetScrollPosition = Math.max(
+ elementMiddle + scrollOffset.value,
+ 0,
+ )
+
+ scroller.scrollToPriority({
+ top: targetScrollPosition,
+ behavior: 'smooth',
+ })
+ }
+ } else {
+ const targetScrollPosition = Math.max(
+ height.value / 2 + scrollOffset.value,
+ 0,
+ )
+ scroller.scrollToPriority({
+ top: targetScrollPosition,
+ behavior: 'smooth',
+ })
+ }
+ }, 5) // let other scroll stuff happen first (scroll compensation etc)
})
return {
// # Misc
loadError,
mobileLayout,
// # Conversation Expansion
isPage,
isExpanded,
toggleExpanded,
// # Focus
focusedId,
setFocused,
// # Main things
conversation,
currentStatus,
getReplies,
// # Misc UI things
getStatusClasses,
// # Linear style stuff
isLinearView,
// ## Linear virtual scrolling
heightChartLinear,
changeSuspendStateLinearLocal,
updateVirtualHeightLinear,
// # Tree style stuff
isTreeView,
// ## Tree virtual scrolling
heightChartAncestors,
changeSuspendStateAncestorsLocal,
updateVirtualHeightAncestors,
changeSuspendStateCurrentLevelLocal,
// ## Tree state
// ### Topology
topLevel,
currentAncestors,
// ### Thread Display
showThreadRecursively,
toggleThreadDisplay,
// ### Derived values and config
treeViewIsSimple,
shouldShowAllConversationButton,
shouldShowAncestors,
// # Scrolling
focusTopLevel,
unsuspendableIds,
}
},
}
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 115bac83ec..71745ff7a1 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,182 +1,183 @@
<template>
<article
ref="root"
class="Conversation"
:class="{ '-expanded' : isExpanded, '-page': isPage, 'panel' : isExpanded }"
:aria-label="conversation[0]?.summary || conversation[0]?.retweeted_status?.summary || $t('timeline.unsubjected_conversation')"
>
<div
v-if="isExpanded"
ref="panelHeader"
class="panel-heading conversation-heading -sticky"
>
<component :is="isPage ? 'h1' : 'h2'" class="title">
<RichContent
v-if="conversation[0]?.summary_raw_html"
:html="conversation[0].summary_raw_html"
:emoji="conversation[0].emojis"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</component>
<button
v-if="!isPage"
class="button-unstyled"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="isPage && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="isPage"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !currentStatus"
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
ref="body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="status.show_all_conversation_with_icon"
tag="button"
class="button-unstyled"
scope="global"
@click.prevent="focusTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $t('status.show_all_conversation', { numStatus: topLevel.length - 1 }, topLevel.length - 1) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
ref="ancestors"
class="thread-ancestors"
>
<template
v-for="element in heightChartAncestors"
:key="element.id"
>
<Status
v-if="element.type === 'item'"
class="conversation-status panel-body"
:class="getStatusClasses(element.id, true)"
:status-id="element.id"
:replies="getReplies(element.id)"
:focused="focusedId === element.id"
conversation-rank="ancestor"
:data-status-id="element.id"
:data-vs-height="element.height"
:data-vs-top="element.top"
:data-vs-suspend="element.suspendable"
@request-focus="setFocused"
@toggle-thread-display="toggleThreadDisplay"
@suspendable-state-change="changeSuspendStateAncestorsLocal"
@height-change="updateVirtualHeightAncestors"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
role="presentation"
:style="{ height: element.height + 'px' }"
aria-hidden="true"
/>
</template>
</div>
<ThreadTree
+ ref="tree"
:status-id="focusedId ?? currentStatus.id"
:depth="0"
@request-focus="setFocused"
@toggle-expanded="toggleExpanded"
@toggle-thread-display="toggleThreadDisplay"
@show-thread-recursively="showThreadRecursively"
@suspendable-state-change="changeSuspendStateCurrentLevelLocal"
/>
</div>
<div
v-else-if="isLinearView"
ref="linear"
class="thread-body linear-view"
>
<template
v-for="element in heightChartLinear"
:key="element.id"
>
<Status
v-if="element.type === 'item'"
class="panel-body conversation-status"
:class="getStatusClasses(element.id)"
:status-id="element.id"
:replies="getReplies(element.id)"
:focused="focusedId === element.id"
:data-status-id="element.id"
:data-vs-height="element.height"
:data-vs-top="element.top"
@request-focus="setFocused"
@toggle-expanded="toggleExpanded"
@toggle-thread-display="toggleThreadDisplay"
@suspendable-state-change="changeSuspendStateLinearLocal"
@height-change="updateVirtualHeightLinear"
/>
<div
v-if="element.type === 'spacer'"
:data-vs-id="element.id"
aria-hidden="true"
class="panel-body virtual-spacer"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
</div>
</article>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index a48230da47..91c1db6b7e 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,1101 +1,1101 @@
import {
debounce,
isEqual,
unescape as ldUnescape,
reject,
uniqBy,
} from 'lodash-es'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Attachment from 'src/components/attachment/attachment.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import { suggest } from 'src/components/emoji_input/suggestor.js'
import Gallery from 'src/components/gallery/gallery.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Popover from 'src/components/popover/popover.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import { useDraftsStore } from 'src/stores/drafts.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 { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBan,
faChevronDown,
faChevronLeft,
faChevronRight,
faCircleNotch,
faPollH,
faQuoteRight,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faQuoteRight,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const PostStatusForm = {
props: {
// Status editing stuff
statusId: String,
statusText: String,
statusSubject: String,
statusIsSensitive: {
type: Boolean,
required: false,
default: null, // Avoiding automatic conversion null -> false
},
statusPoll: Object,
statusQuote: Object,
statusFiles: Array,
statusMediaDescriptions: Object,
statusVisibility: String,
statusContentType: String,
// Replies/mentions
repliedStatus: Object, // Object of a status replying to
profileMention: Object, // Mentioned user (used in profile page -> mention)
// Draft stuff
hideDraft: Boolean, // Disable drafts functionality
closeable: Boolean, // Whether form can be closed (i.e. in replies)
draftId: String, // ID of the draft to be used
// Chats stuff
chatView: Boolean, // Used for the "submit on enter" user setting
maxHeight: Number,
placeholder: String,
postHandler: Function, // Used to override poster to use chats one instead of status one
preserveFocus: Boolean, // Keep focus on form after posting
autoFocus: Boolean, // Steal focus when form is opened
fileLimit: Number, // Chats only support 1 attachment :(
emojiPickerPlacement: String,
optimisticPosting: Boolean, // Don't wait for confirmation that post is done
// Feature toggles for special cases (mostly chats)
mentionsLine: Boolean, // Use separate field for specifying mentions
mentionsLineReadOnly: Boolean, // Make the said field read-only (for chat conversation view)
disableSubject: Boolean,
disableScopeSelector: Boolean,
disableVisibilitySelector: Boolean,
disableNotice: Boolean,
disableLockWarning: Boolean,
disablePolls: Boolean,
disableQuotes: Boolean,
disableSensitivityCheckbox: Boolean,
disableSubmit: Boolean,
disablePreview: Boolean,
disableDraft: Boolean,
},
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'close-accepted',
'update',
],
data() {
return {
initialized: false,
randomSeed: genRandomSeed(),
// Posting stuff
idempotencyKey: '',
/* Data is initialized first, but we have no access to .computed yet
* which we need for some defaults (i.e. user configration)
* so we pre-fill with stuff meant for status editing and later
* back-fill with defaults in .created()
*/
newStatus: {
status: this.statusText ?? null,
mentions: this.statusMentionLine ?? null,
spoilerText: this.statusSubject ?? null,
quote: this.statusQuote ?? null,
files: this.statusFiles ?? null,
poll: this.statusPoll ?? null,
mediaDescriptions: this.statusMediaDescriptions ?? null,
nsfw: this.statusIsSensitive ?? null,
visibility: this.statusVisibility ?? null,
contentType: this.statusContentType ?? null,
},
// Attachments
dropFiles: [],
uploadingFiles: false,
showDropIcon: 'hide',
dropStopTimeout: null,
// Preview
preview: null,
previewLoading: false,
// Draft
saveInhibited: true,
saveable: false,
// Misc States
emojiInputShown: false,
error: null,
posting: false,
}
},
components: {
MediaUpload,
EmojiInput,
PollForm: defineAsyncComponent(
() => import('src/components/poll/poll_form.vue'),
),
QuoteForm: defineAsyncComponent(
() => import('src/components/quote/quote_form.vue'),
),
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
Popover,
},
created() {
this.updateIdempotencyKey()
// If we are starting a new post, do not associate it with old drafts
const draft =
!this.disableDraft && (this.draftId || this.statusType !== 'new')
? this.getDraft(this.statusType, this.refId)
: null
if (draft) {
// Copying and overriding defaults from the draft for each field
Object.keys(this.newStatus).forEach((key) => {
this.newStatus[key] = draft[key] ?? this.newStatus[key]
})
} else {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
this.newStatus[key] = this.newStatus[key] ?? value
})
}
this.initialized = true
},
mounted() {
this.resize(this.$refs.textarea)
if (this.repliedStatus) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.repliedStatus || this.autoFocus) {
this.$refs.textarea.focus()
}
},
computed: {
// Visibility / expansion state of subcomponents
pollFormVisible() {
return this.hasPoll
},
quoteFormVisible() {
return this.hasQuote && !this.newStatus.quote.thread
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
// Composing stuff
statusType() {
if (this.repliedStatus) {
return 'reply'
} else if (this.profileMention) {
return 'mention'
} else if (this.statusId) {
return 'edit'
} else {
return 'new'
}
},
refId() {
if (this.repliedStatus) {
return this.repliedStatus.id
} else if (this.profileMention) {
return this.profileMention.id
} else if (this.statusId) {
return this.statusId
} else {
return null
}
},
mentionsString() {
if (this.statusType !== 'reply' && this.statusType !== 'mention')
return ''
let allAttentions = [...(this.repliedStatus?.attentions || [])]
const repliedUser = this.repliedStatus?.user || this.profileMention
if (repliedUser) allAttentions.unshift(repliedUser)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: this.currentUser.id })
const mentions = allAttentions.map(
(attention) => `@${attention.screen_name}`,
)
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
},
newStatusContent() {
return this.mentionsLine
? this.mentionsString + this.newStatus.status
: this.newStatus.status
},
defaultNewStatus() {
const defaultNewStatus = {
files: [],
poll: null,
quote: null,
mediaDescriptions: {},
}
const scope = (() => {
if (this.repliedStatus) {
if (this.repliedStatus.visibility === 'direct') return 'direct'
if (this.userDefaultScopeCopy) return this.repliedStatus.visibility
}
return this.userDefaultScope
})()
const preset = this.$route.query.message
const statusText = preset ?? ''
if (this.mentionsLine) {
defaultNewStatus.status = statusText
} else {
defaultNewStatus.status = this.mentionsString + statusText
}
defaultNewStatus.mentions = this.mentionsString.trim()
defaultNewStatus.spoilerText = this.repliedSubjectString ?? ''
defaultNewStatus.nsfw = this.userDefaultSensitive
defaultNewStatus.visibility = scope
defaultNewStatus.contentType = this.userDefaultPostContentType
return defaultNewStatus
},
// -Edit
isEdit() {
return this.statusId !== undefined && this.statusId.trim() !== ''
},
// -Reply
isReply() {
return this.statusType === 'reply'
},
inReplyToStatusId() {
return !this.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.repliedStatus?.id
: undefined
},
repliedSubjectString() {
if (!this.repliedStatus?.summary) return null
const decodedSummary = ldUnescape(this.repliedStatus.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
// -Poll
hasPoll() {
return this.newStatus.poll != null
},
// -Quotes
hasQuote() {
return this.newStatus.quote !== null
},
quotable() {
if (!this.quotingAvailable || !this.isReply) {
return false
}
if (
this.repliedStatus.visibility === 'public' ||
this.repliedStatus.visibility === 'unlisted' ||
this.repliedStatus.visibility === 'local'
) {
return true
} else if (this.repliedStatus.visibility === 'private') {
return this.repliedStatus.user.id === this.currentUser.id
}
return false
},
quoteId() {
return this.newStatus.quote?.id ?? null
},
// This is for "reply/quote" toggle
quoteThreadToggled: {
get() {
return this.newStatus.quote?.thread
},
set(value) {
if (value) {
this.newStatus.quote = {}
this.newStatus.quote.thread = true
this.newStatus.quote.id = this.repliedStatus.id
} else {
this.newStatus.quote = null
}
},
},
postingOptions() {
const poll = this.hasPoll ? pollFormToMasto(this.newStatus.poll) : null
return {
status: this.newStatusContent,
spoilerText: this.newStatus.spoilerText,
visibility: this.newStatus.visibility,
sensitive: this.newStatus.nsfw,
media: this.newStatus.files,
inReplyToStatusId: this.inReplyToStatusId,
quoteId: this.quoteId,
contentType: this.newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
}
},
// Emoji stuff
emojiUserSuggestor() {
return suggest({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emojiSuggestor() {
return suggest({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emoji() {
return useEmojiStore().standardEmojiList
},
customEmoji() {
return useEmojiStore().customEmoji
},
// Length & Limits
statusLength() {
return this.newStatusContent.length
},
spoilerTextLength() {
return this.newStatus.spoilerText.length
},
statusLengthLimit() {
return useInstanceStore().limits.textLimit
},
hasStatusLengthLimit() {
return this.statusLengthLimit > 0
},
charactersLeft() {
return (
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
)
},
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
isEmptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
// Drafts
isDirty() {
return Object.entries(this.defaultNewStatus).some(
([key, defaultValue]) => {
const actualValue = this.newStatus[key]
if (actualValue === null) return false
return !isEqual(actualValue, defaultValue)
},
)
},
shouldAutoSaveDraft() {
return useMergedConfigStore().mergedConfig.autoSaveDraft
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
autoSaveState() {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft() {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote
)
)
},
// Error handling
pollContentError() {
return this.pollFormVisible && this.newStatus.poll?.error
},
// Featureset detection
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
// User configuration
userDefaultScope() {
return this.currentUser.default_scope
},
userDefaultPostContentType() {
return this.mergedConfig.postContentType
},
userDefaultScopeCopy() {
return this.mergedConfig.scopeCopy
},
userDefaultSensitive() {
return this.mergedConfig.sensitiveByDefault
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
minimalScopesMode() {
return this.mergedConfig.minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
hideScopeNotice() {
return (
this.disableNotice ||
useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
submitOnEnter() {
return this.chatView && this.mergedConfig.chatSubmitOnEnter
},
// Global stuff
...mapState(useUsersStore, ['currentUser']),
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (state) => state.mobileLayout,
}),
},
watch: {
newStatus: {
deep: true,
handler() {
this.statusChanged()
},
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
this.addBeforeUnloadListener()
} else {
this.removeBeforeUnloadListener()
}
},
},
methods: {
// Composing
update() {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
if (key === 'status') return
this.newStatus[key] = value
})
},
onMentionsLineUpdate(e) {
if (this.mentionsLineReadOnly) return
this.newStatus.mentions = e
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
clearStatus() {
this.saveInhibited = true
this.newStatus.status = ''
this.newStatus.mentions = ''
this.newStatus.spoilerText = ''
this.newStatus.files = []
this.newStatus.poll = null
this.newStatus.quote = null
this.newStatus.nsfw = this.defaultNewStatus.nsfw
this.newStatus.mediaDescriptions = {}
this.$refs.mediaUpload?.clearFile()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.saveable = false
},
async postStatus(event) {
if (this.posting && !this.optimisticPosting) {
return
}
if (this.disableSubmit) {
return
}
if (this.emojiInputShown) {
return
}
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (
this.optimisticPosting &&
(this.isEmptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.isEmptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
postHandler(this.postingOptions)
.then((data) => {
this.abandonDraft()
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('posted', data)
})
.catch((error) => {
this.error = error
})
.finally(() => {
this.posting = false
})
},
// Preview
previewStatus() {
if (this.isEmptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
this.previewLoading = true
statusPoster
.postStatus({
...this.postingOptions,
media: [],
poll: null,
preview: true,
})
.then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
this.preview = data
})
.catch((error) => {
this.preview = { error }
})
.finally(() => {
this.previewLoading = false
// If we post right after doing a preview request
// backend treats non-preview request as preview one
// and "eats" the post
this.updateIdempotencyKey()
})
},
debouncePreviewStatus: debounce(function () {
this.previewStatus()
}, 500),
autoPreview() {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview() {
this.preview = null
this.previewLoading = false
},
togglePreview() {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
// Attachments
setMediaDescription(id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({
id,
description,
})
},
setAllMediaDescriptions() {
const ids = this.newStatus.files.map((file) => file.id)
return Promise.all(ids.map((id) => this.setMediaDescription(id)))
},
addMediaFile(fileInfo) {
this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
},
removeMediaFile(fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment(fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
if (index === 0) return
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
if (index === files.length - 1) return
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed(errString, templateArgs) {
templateArgs = templateArgs || {}
this.error =
this.$t('upload.error.base') +
' ' +
this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles() {
this.uploadingFiles = true
},
finishedUploadingFiles() {
this.$emit('resize')
this.uploadingFiles = false
},
paste(e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop(e) {
if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop() {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag(e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer?.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
// Auto-sizable input field
// TODO separate into its own component pls
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize(e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr =
window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef =
this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight
? Math.min(heightWithoutPadding, this.maxHeight)
: heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder =
bottomRef.offsetHeight +
findOffset(bottomRef, scrollerRef).top +
bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom =
isBottomObstructed &&
!(
isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !==
this.$refs.textarea.value.length
)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
// Poll
togglePollForm() {
this.newStatus.poll = this.hasPoll ? null : {}
},
setPoll(poll) {
this.newStatus.poll = poll
},
// Quote
toggleQuoteForm() {
// This is for the "attach quote" button
if (this.newStatus.quote?.thread) return
if (!this.hasQuote) {
this.newStatus.quote = {}
this.newStatus.quote.thread = false
this.newStatus.quote.id = null
this.newStatus.quote.url = ''
} else {
this.newStatus.quote = null
}
},
// Drafts
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
- this.saveable = true
+ this.saveable = this.isDirty
this.saveInhibited = false
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return useDraftsStore()
.addOrSaveDraft({
type: this.statusType,
refId: this.refId,
...this.newStatus,
// Draft ID overwrites status ID (which is undefined for fresh statuses)
id: this.draftId,
})
.then((id) => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('draft-done')
}
})
} else if (this.hasEmptyDraft) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft().then(() => {
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return useDraftsStore().abandonDraft(this.draftId)
},
getDraft() {
const maybeDraft = useDraftsStore().drafts.get(this.draftId)
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = useDraftsStore().draftsByTypeAndRefId(
this.statusType,
this.refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('close-accepted')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('close-accepted')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('close-accepted')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
// Misc
propsToNative(props) {
return propsToNative(props)
},
handleEmojiInputShow(value) {
this.emojiInputShown = value
},
updateIdempotencyKey() {
this.idempotencyKey = Date.now().toString()
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
dismissScopeNotice() {
useSyncConfigStore().setSimplePrefAndSave({
path: 'hideScopeNotice',
value: true,
})
},
clearError() {
this.error = null
},
...mapActions(useMediaViewerStore, ['increment']),
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
}
export default PostStatusForm
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index de844e5a01..ebbc429e0a 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -1,174 +1,175 @@
<template>
<!-- there is a brief moment during logout when old timeline gets forcibly deactivated -->
<div
v-if="timeline.fetcher"
:class="['Timeline', classes.root]"
>
<div
v-if="!embedded"
:class="classes.header"
>
<TimelineMenu
v-if="!embedded"
:timeline-name="timelineRef.name"
:id="'timeline-header-' + uniqueId"
/>
<div
v-if="timeline.fetcher.loadingNewer && !showLoadButton"
class="loadingIndicator"
>
<FAIcon
fixed-width
icon="circle-notch"
spin
/>
</div>
<ScrollTopButton />
<template v-if="mobileLayout">
<div
v-if="showLoadButton"
class="rightside-button"
>
<button
class="button-unstyled loadmore-button"
:title="loadButtonString"
@click.prevent="showNewStatuses(false)"
>
<FAIcon
fixed-width
icon="circle-plus"
/>
<div class="badge -counter">
{{ mobileLoadButtonString }}
</div>
</button>
</div>
<div
v-else-if="!timeline.fetcher.loadingNewer"
class="loadmore-text faint veryfaint rightside-icon"
:title="$t('timeline.up_to_date')"
:aria-disabled="true"
@click.prevent
>
<FAIcon
fixed-width
icon="check"
/>
</div>
</template>
<template v-else>
<button
v-if="showLoadButton"
class="button-default loadmore-button"
@click.prevent="showNewStatuses(false)"
>
{{ loadButtonString }}
</button>
<div
v-else
class="loadmore-text faint"
@click.prevent
>
{{ $t('timeline.up_to_date') }}
</div>
</template>
<QuickFilterSettings
v-if="!mobileLayout"
class="rightside-button"
/>
<QuickViewSettings
class="rightside-button"
/>
</div>
<div :class="classes.body">
<div
class="timeline"
ref="timeline"
role="feed"
:aria-labelledby="embedded ? undefined : ('timeline-header-'+ uniqueId)"
>
<template
v-for="element in heightChart"
:key="element.id"
>
<Conversation
v-if="element.type === 'item'"
class="timeline-conversation"
:status-id="element.id"
:scroll-compensation="element.visible"
+ :top="element.top"
:data-vs-height="element.height"
:data-vs-top="element.top"
:data-vs-id="element.id"
:data-vs-visible="element.visible"
@suspendable-state-change="changeSuspendState"
@height-change="updateVirtualHeight"
/>
<div
v-if="element.type === 'spacer'"
class="virtual-spacer"
aria-hidden="true"
:style="{ height: element.height + 'px' }"
/>
</template>
</div>
<template v-if="!hideEmpty && count === 0">
<div
v-if="timeline.fetcher.loadingNewer || timeline.fetcher.loadingOlder"
class="timeline-placeholder"
>
<FAIcon
icon="circle-notch"
spin
size="4x"
/>
</div>
<div
v-else
class="timeline-placeholder faint"
>
{{ $t('timeline.no_statuses') }}
</div>
</template>
</div>
<div v-if="!embedded || footerSlipgate" :class="classes.footer">
<teleport
:to="footerSlipgate"
:disabled="!embedded || !footerSlipgate"
>
<div
v-if="timeline.fetcher.bottomedOut"
class="new-status-notification text-center faint"
>
{{ $t('timeline.no_more_statuses') }}
</div>
<div
v-else-if="timeline.fetcher.loadingOlder"
class="new-status-notification text-center"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<button
v-else-if="timeline.minId !== ''"
class="button-unstyled -link"
@click.prevent="fetchOlderStatuses()"
>
<div class="new-status-notification text-center">
{{ $t('timeline.load_older') }}
</div>
</button>
</teleport>
<!-- spacer to avoid having empty shrug -->
<span v-if="embedded && footerSlipgate" />
</div>
</div>
</template>
<script src="./timeline.js"></script>
<style src="./timeline.scss" lang="scss"> </style>
diff --git a/src/composables/useScrollPosition.js b/src/composables/useScrollPosition.js
index 3201d0d24e..a1c2a737cd 100644
--- a/src/composables/useScrollPosition.js
+++ b/src/composables/useScrollPosition.js
@@ -1,105 +1,109 @@
import { computed, ref } from 'vue'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
export function useScrollPosition(scroller) {
const {
x,
y,
cWidth,
cHeight,
vWidth,
vHeight,
scrollBy: realScrollBy,
scrollTo: realScrollTo,
} = scroller
const priorityInProgress = ref(false)
const top = computed(() => y.value)
const bottom = computed(() => y.value + vHeight.value)
const left = computed(() => x.value)
const right = computed(() => x.value + vWidth.value)
const distanceToTop = top
const distanceToLeft = left
const distanceToBottom = computed(() => cHeight.value - bottom.value)
const distanceToRight = computed(() => cWidth.value - right.value)
const { fontSize } = useInterfaceSizes()
const reachMargin = computed(() => fontSize.value)
const hasReachedTop = computed(() => distanceToTop.value < reachMargin.value)
const hasReachedBottom = computed(
() => distanceToBottom.value < reachMargin.value,
)
const hasReachedLeft = computed(
() => distanceToLeft.value < reachMargin.value,
)
const hasReachedRight = computed(
() => distanceToRight.value < reachMargin.value,
)
const loadMargin = computed(() => fontSize.value * 25)
const shouldLoadTop = computed(() => distanceToTop.value < loadMargin.value)
const shouldLoadBottom = computed(
() => distanceToBottom.value < loadMargin.value,
)
const shouldLoadLeft = computed(() => distanceToLeft.value < loadMargin.value)
const shouldLoadRight = computed(
() => distanceToRight.value < loadMargin.value,
)
const scrollTo = async (...args) => {
if (priorityInProgress.value) return true
return await realScrollTo(...args)
}
const scrollBy = async (...args) => {
if (priorityInProgress.value) return true
return await realScrollBy(...args)
}
const scrollToPriority = async (...args) => {
priorityInProgress.value = true
const result = await realScrollTo(...args)
- priorityInProgress.value = false
+ setTimeout(() => {
+ priorityInProgress.value = false
+ }, 100)
return result
}
const scrollByPriority = async (...args) => {
priorityInProgress.value = true
const result = await realScrollBy(...args)
- priorityInProgress.value = false
+ setTimeout(() => {
+ priorityInProgress.value = false
+ }, 1000)
return result
}
return {
top,
left,
right,
bottom,
distanceToTop,
distanceToLeft,
distanceToRight,
distanceToBottom,
cWidth,
cHeight,
vWidth,
vHeight,
hasReachedLeft,
hasReachedRight,
hasReachedTop,
hasReachedBottom,
shouldLoadLeft,
shouldLoadRight,
shouldLoadTop,
shouldLoadBottom,
scrollBy,
scrollTo,
scrollByPriority,
scrollToPriority,
}
}
diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js
index 8db08dae57..cdb13d813a 100644
--- a/src/composables/useVirtualScrolling.js
+++ b/src/composables/useVirtualScrolling.js
@@ -1,397 +1,398 @@
import { first, last, throttle } from 'lodash-es'
import { computed, nextTick, ref, toValue, watch } from 'vue'
export function useVirtualScrolling({
// For debugging
name = 'Generic',
// Master toggle
enabled,
// List of items
list,
// Container of items, used for measuring scroll position
body,
// vertical offset to account for fixed and sticky headers
offset,
// useScrollPosition composable, used to prevent dupicating instances
scrollPositionInstance,
// buffer zone, the amount of placeholder heights to include
buffer,
// whether to use scroll compensation when elements above anchor change
scrollCompensation,
// How to handle collapse/expansion (going from 0 elements to full and back)
// - false - don't do scroll compensation at all
// - 'height' - compensate scroll according to list's height
// - 'item' - same as height but uses anchor element's top offset
// instead of whole height
collapseMode,
// Placeholder height specification. Must be a function.
// function will be called either:
// - without arguments (for generic placeholder, i.e. buffer zone size)
// - with id (for specific item placeholders)
// function must return ref pointing to height
getPlaceholderHeight,
// Invert grow direction. Useful for chat views. Makes scroll compensation
// work in reverse - adding items to bottom scrolls down, adding items to
// top doesn't scroll up.
invertDirection = false,
}) {
// # Suspension
const unsuspendibleIds = ref(new Set())
const changeSuspendState = ({ id, suspendable }) => {
if (suspendable) {
unsuspendibleIds.value.delete(id)
} else {
unsuspendibleIds.value.add(id)
}
}
// # Heights mapping.
const heights = ref(new Map())
const heightChart = computed(() => {
// Map every height and suspendable state
const chart = list.value.map((item) => {
const { id } = item
const height = (() => {
if (heights.value.has(id)) {
return heights.value.get(id)
} else {
return getPlaceholderHeight(id).value
}
})()
const suspendable = !unsuspendibleIds.value.has(id)
const real = heights.value.has(id)
return { id, height, suspendable, real, visible: true }
})
// Walk over the list to set top offsets
chart.reduce((sum, item) => {
item.top = sum
return sum + item.height
}, 0)
return chart
})
const updateVirtualHeight = ({ id, height }) => {
heights.value.set(id, height)
}
const {
top: scrollY,
scrollBy,
vHeight,
hasReachedTop,
hasReachedBottom,
} = scrollPositionInstance
// Real scroll boundary, relative to body's bounds
const topScrollBoundary = ref(0)
const bottomScrollBoundary = ref(0)
const updateBoundaries = () => {
if (!toValue(enabled)) return
if (!body.value) return // Not mounted yet
const { top } = body.value.getBoundingClientRect()
const distanceItemTopToWindowTop = 0 - top + (toValue(offset) ?? 0)
const distanceItemTopToWindowBottom = vHeight.value - top
// Technically, bottom scroll boundary should be distance
// from element's top border to window's bottom border,
// but it just so happens that it is equal to this.
// You can verify it by drawing the boxes and measuring
// distances yourself, I know I did. Geometry, man...
topScrollBoundary.value = distanceItemTopToWindowTop
bottomScrollBoundary.value = distanceItemTopToWindowBottom
}
const windowWatcher = watch(vHeight, updateBoundaries)
const scrollWatcher = watch(scrollY, updateBoundaries)
const heightWatcher = watch(heightChart, updateBoundaries)
const bodyWatcher = watch(body, updateBoundaries)
watch(vHeight, (neu, old) => {
if (toValue(invertDirection)) {
// Only compensate (stick to bottom) when shrinking
scrollBy(0, Math.max(old - neu, 0))
// compensating in other direction causes overscrolling
}
})
const pauseWatchers = () => {
windowWatcher.pause()
scrollWatcher.pause()
heightWatcher.pause()
bodyWatcher.pause()
}
const resumeWatchers = (skipUpdate = false) => {
windowWatcher.resume()
scrollWatcher.resume()
heightWatcher.resume()
bodyWatcher.resume()
if (skipUpdate) return
updateBoundaries()
}
watch(enabled, (val) => {
if (val) {
resumeWatchers()
} else {
pauseWatchers()
}
})
// # Visiblity
// Add buffer zone to boundary, equal to approx 10 items heights
const bufferZone = computed(
() => getPlaceholderHeight().value * (toValue(buffer) ?? 5),
)
// Stuff that's on screen
const checkVisible = ({ top, height }) => {
const itemTopBoundary = top
const itemBottomBoundary = top + height
// Include buffer zone
const finalTopScrollBoundary = topScrollBoundary.value
const finalBottomScrollBoundary = bottomScrollBoundary.value
// To be visible, item's bottom boundary shoud be below top scroll boundary)
const isBelowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
// To be visible, item's top boundary shoud be above bottom scroll boundary)
const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary
return isBelowTopBoundary && isAboveBottomBoundary
}
// Same thing but including buffer zone
const checkRendered = ({ top, height }) => {
const itemTopBoundary = top
const itemBottomBoundary = top + height
// Include buffer zone
const finalTopScrollBoundary = topScrollBoundary.value - bufferZone.value
const finalBottomScrollBoundary =
bottomScrollBoundary.value + bufferZone.value
// To be visible, item's bottom boundary shoud be below top scroll boundary)
const isBelowTopBoundary = itemBottomBoundary > finalTopScrollBoundary
// To be visible, item's top boundary shoud be above bottom scroll boundary)
const isAboveBottomBoundary = itemTopBoundary < finalBottomScrollBoundary
// This accounts for the case where item's boundaries exceed scroll boundary
return isBelowTopBoundary && isAboveBottomBoundary
}
const heightChartVisibility = ref(null)
const recalculateChartVisibility = throttle(
(newVal, oldVal) => {
heightChartVisibility.value = heightChart.value.map(
(heightChartItem) => ({
...heightChartItem,
visible: checkVisible(heightChartItem),
rendered: checkRendered(heightChartItem),
}),
)
},
32, // 32ms = ~30fps
{ leading: true, trailing: true },
)
watch(
[heightChart, topScrollBoundary, bottomScrollBoundary, bufferZone],
recalculateChartVisibility,
{ immediate: true },
)
const heightChartGrouped = computed(() => {
const chart = heightChartVisibility
// Group invisible items into spacers
return chart.value.reduce((acc, heightChartItem, index) => {
const { suspendable, rendered, visible, height, top, id } =
heightChartItem
const present = rendered || !suspendable || !enabled.value
if (present) {
return [
...acc,
{ type: 'item', height, top, id, visible, rendered, present },
]
} else {
// Reusing previous item if possible
const previousItem = acc[acc.length - 1]
const usingPreviousItem = previousItem?.type === 'spacer'
// We only really care for height and id of spacer, everything else
// is just for debugging
const spacer = usingPreviousItem
? previousItem
: {
type: 'spacer',
top: Number.POSITIVE_INFINITY,
height: 0,
id: `i${index}`, // used for v-for key attribute
ids: new Set(),
}
spacer.ids.add(id)
spacer.height += height
if (top < spacer.top) spacer.top = top
// If we used previous item there is no need to push it to array
if (usingPreviousItem) {
return acc
} else {
return [...acc, spacer]
}
}
}, [])
})
// ## Scroll compensation
watch(
heightChartVisibility,
async (newVal, oldVal) => {
if (newVal.length === 0 && oldVal.length === 0) return 0
const explosion = oldVal.length === 0 && newVal.length !== 0
const implosion = oldVal.length !== 0 && newVal.length === 0
const compensation = toValue(scrollCompensation)
const diff = (() => {
if (explosion) {
if (!compensation) return 0
if (toValue(collapseMode) === 'height' || toValue(invertDirection)) {
const newBottomElement = last(newVal)
return newBottomElement.top + newBottomElement.height
} else {
return 0
}
} else if (implosion) {
if (!compensation) return 0
const oldBottomElement = last(oldVal)
return 0 - oldBottomElement.top - oldBottomElement.height
} else {
const contextChange = (() => {
// HACK ignore date separators and fake messages in chat view
const oldIds = new Set(
oldVal
.map(({ id }) => id)
.filter((id) => !id.startsWith('fake-')),
)
const newIds = new Set(
newVal
.map(({ id }) => id)
.filter((id) => !id.startsWith('fake-')),
)
if (oldVal.length <= newVal.length) {
return [...oldIds].some((id) => !newIds.has(id))
} else {
return [...newIds].some((id) => !oldIds.has(id))
}
})()
if (contextChange) {
return 0
}
const isSticking = toValue(invertDirection)
? hasReachedBottom.value
: hasReachedTop.value
if (!compensation) {
return 0
}
// Section is used for debugging only
const getShift = (section, a, b, find, getter) => {
const aItem = find(a)
if (!aItem) return 0
const bItem = b.find(({ id }) => id === aItem.id)
if (!bItem) return 0
return getter(bItem) - getter(aItem)
}
const expansion = (() => {
if (newVal.length < oldVal.length) return 0
return getShift(
'expansion',
oldVal,
newVal,
(list) =>
list.find(
({ top, height }) => top + height < topScrollBoundary.value,
) ?? first(list),
({ top, height }) => top + height,
)
})()
const inverseCompensation = (() => {
if (!toValue(invertDirection)) return 0
if (newVal.length < oldVal.length) return 0
const shift = getShift(
'inverse',
oldVal,
newVal,
(list) =>
list.findLast(({ top }) => top > bottomScrollBoundary.value) ??
last(list),
({ top }) => top,
)
if (isSticking) {
// Don't do 99999 here, it breaks virtual scrolling
const { height } = body.value.getBoundingClientRect()
return height // keep sticking
} else {
return shift
}
})()
const collapsing = (() => {
if (newVal.length >= oldVal.length) return 0
return -getShift(
'collapse',
newVal,
oldVal,
(list) => first(list),
({ top, height }) => top + height,
)
})()
return (
(toValue(invertDirection) ? inverseCompensation : expansion) +
collapsing
)
}
})()
if (diff !== 0) {
// Scroll by amount offset changed to keep it in view
topScrollBoundary.value += diff
bottomScrollBoundary.value += diff
await nextTick()
await scrollBy(0, diff)
}
resumeWatchers()
},
{ flush: 'post' },
)
// Misc
const reset = () => {
unsuspendibleIds.value = new Set()
heights.value = new Map()
}
return {
heightChart: heightChartGrouped,
+ heightChartFull: heightChartVisibility,
changeSuspendState,
updateVirtualHeight,
pauseWatchers,
resumeWatchers,
updateBoundaries,
reset,
}
}
diff --git a/src/stores/sync_config.js b/src/stores/sync_config.js
index 26c447530e..bd123dd4a0 100644
--- a/src/stores/sync_config.js
+++ b/src/stores/sync_config.js
@@ -1,860 +1,861 @@
import sum from 'hash-sum'
import {
merge as _merge,
clamp,
cloneDeep,
findLastIndex,
get,
groupBy,
isEqual,
last,
set,
take,
uniqWith,
unset,
} from 'lodash-es'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { toRaw } from 'vue'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useUsersStore } from 'src/stores/users.js'
import { updateProfileJSON } from 'src/api/user.js'
import { storage } from 'src/lib/storage.js'
import {
makeUndefined,
ROOT_CONFIG,
ROOT_CONFIG_DEFINITIONS,
validateSetting,
} from 'src/modules/default_config_state.js'
import { oldDefaultConfigSync } from 'src/modules/old_default_config_state.js'
export const VERSION = 2
export const NEW_USER_DATE = new Date('2026-03-16') // date of writing this, basically
export const COMMAND_TRIM_FLAGS = 1000
export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
export const COMMAND_WIPE_JOURNAL = 1010
export const COMMAND_WIPE_JOURNAL_AND_STORAGE = 1011
export const defaultState = {
// do we need to update data on server?
dirty: false,
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
reset: 0, // special flag that can be used to force-reset all data, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
muteFilters: {},
...makeUndefined({ ...ROOT_CONFIG }),
},
collections: {
pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
pinnedNavItems: ['home', 'dms', 'chats'],
},
},
// raw data
raw: null,
// local cache
cache: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
updateCounter: CURRENT_UPDATE_COUNTER, // new users don't need to see update notification
}
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
_version: VERSION,
}
}
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {},
}
// Simple
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
if (v === undefined) return
if (typeof v === 'number' || typeof v === 'boolean') return
if (typeof v === 'object') return
console.warn(
`Preference simple.${k} as invalid type ${typeof v}, reinitializing`,
)
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
// Collections
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
console.warn(
`Preference collections.${k} as invalid type ${typeof v}, reinitializing`,
)
set(
state.prefsStorage.collections,
k,
defaultState.prefsStorage.collections[k],
)
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug('Both sources have valid data, figuring things out...')
if (
live._timestamp === cache._timestamp &&
live._version === cache._version
) {
console.debug(
'Same version/timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'Different timestamp or version, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_version: a._version ?? b._version,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
prefsStorage: _merge(cloneDeep(a.prefsStorage), b.prefsStorage),
flagStorage: _merge(a.flagStorage, b.flagStorage),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
export const _getAllFlags = (recent, stale) => {
const recentStorage = toRaw(recent?.flagStorage)
const staleStorage = toRaw(stale?.flagStorage)
return Array.from(
new Set([
...Object.keys(recentStorage || {}),
...Object.keys(staleStorage || {}),
]),
)
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(
allFlagKeys.map((flag) => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
// use flag that is of higher value
return [
flag,
Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0),
]
}),
)
}
export const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = journals
.map((j) => (Array.isArray(j) ? j : []))
.flat()
.filter(
(entry) =>
Object.hasOwn(entry, 'path') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'path')
const trimmedGrouped = Object.entries(grouped).map(([path, rawJournal]) => {
const journal = rawJournal
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
if (path.startsWith('collections')) {
const lastRemoveIndex = findLastIndex(
journal,
({ operation }) => operation === 'removeFromCollection',
)
// everything before last remove is unimportant
let remainder
if (lastRemoveIndex > 0) {
remainder = journal.slice(lastRemoveIndex)
} else {
// everything else doesn't need trimming
remainder = journal
}
return uniqWith(remainder, (a, b) => {
if (a.path !== b.path) {
return false
}
if (a.operation !== b.operation) {
return false
}
if (a.operation === 'addToCollection') {
return a.args[0] === b.args[0]
}
return false
})
} else if (path.startsWith('simple')) {
// Only the last record is important
return [last(journal)]
} else {
return journal
}
})
const flat = trimmedGrouped
.flat()
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
return take(flat, 500)
}
export const _mergePrefs = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentData } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* path: path to entry in prefsStorage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
* shouldn't be used with collections!
*/
const resultOutput = { ...recentData }
const totalJournal = _mergeJournal(staleJournal, recentJournal)
totalJournal
.filter(({ path, operation, args }) => {
const entry = path.split('.')[1]
if (operation === 'unset') return ROOT_CONFIG[entry] !== undefined
if (operation !== 'set') return true
const definition = path.startsWith('simple.muteFilters')
? { default: {} }
: ROOT_CONFIG_DEFINITIONS[entry]
const finalValue = validateSetting({
path,
value: args[0],
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
return finalValue !== undefined
})
.forEach(({ path, operation, args }) => {
if (path.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
case 'set': {
if (path.startsWith('collections')) {
return console.error('Illegal operation "set" on a collection')
}
if (path.split(/\./g).length <= 1) {
return console.error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
set(resultOutput, path, args[0])
break
}
case 'unset':
if (path.startsWith('collections')) {
return console.error('Illegal operation "unset" on a collection')
}
if (path.split(/\./g).length <= 2) {
return console.error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
unset(resultOutput, path)
break
case 'addToCollection':
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "addToCollection" on a non-collection',
)
}
set(
resultOutput,
path,
Array.from(new Set(get(resultOutput, path)).add(args[0])),
)
break
case 'removeFromCollection': {
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "removeFromCollection" on a non-collection',
)
}
const newSet = new Set(get(resultOutput, path))
newSet.delete(args[0])
set(resultOutput, path, Array.from(newSet))
break
}
case 'reorderCollection': {
const [value, movement] = args
set(
resultOutput,
path,
_moveItemInArray(get(resultOutput, path), value, movement),
)
break
}
default:
return console.error(
`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`,
)
}
})
return { ...resultOutput, _journal: totalJournal }
}
export const _resetFlags = (
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
// flag reset functionality
if (
totalFlags.reset >= COMMAND_TRIM_FLAGS &&
totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET
) {
console.debug('Received command to trim the flags')
const knownKeysSet = new Set(Object.keys(knownKeys))
// Trim
result = {}
allFlagKeys.forEach((flag) => {
if (knownKeysSet.has(flag)) {
result[flag] = totalFlags[flag]
}
})
// Reset
if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
// 1001 - and reset everything to 0
console.debug('Received command to reset the flags')
Object.keys(knownKeys).forEach((flag) => {
result[flag] = 0
})
}
}
result.reset = 0
return result
}
const _resetPrefs = (
totalPrefs,
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
// prefs reset functionality
if (
totalFlags.reset >= COMMAND_WIPE_JOURNAL &&
totalFlags.reset <= COMMAND_WIPE_JOURNAL_AND_STORAGE
) {
console.debug('Received command to reset journals')
this.flagStorage.reset = COMMAND_WIPE_JOURNAL
this.prefsStorage._journal = []
this.cache.prefsStorage._journal = []
this.raw.prefsStorage._journal = []
this.pushSyncConfig()
if (totalFlags.reset === COMMAND_WIPE_JOURNAL_AND_STORAGE) {
console.debug('Received command to reset storage')
return cloneDeep(defaultState)
}
}
return totalPrefs
}
const _doMigrations = async (data, setPreference) => {
if (data._version < VERSION) {
console.debug(
'Data has older version, seeing if there any migrations that can be applied',
)
}
if (data._version > VERSION) {
console.debug(
'Data has newer version, seeing if there any reverse migrations that can be applied',
)
// no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
if (window._PLEROMA_HOTPATCH) {
if (window._PLEROMA_HOTPATCH.reverseMigrations) {
console.debug('Found hotpatch migration, applying')
return window._PLEROMA_HOTPATCH.reverseMigrations.call(
{},
'syncConfigStore',
{ from: data._version, to: VERSION },
data,
)
}
}
}
return data
}
export const useSyncConfigStore = defineStore('sync_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
setFlag({ flag, value }) {
this.flagStorage[flag] = value
this.dirty = true
},
setSimplePrefAndSave({ path, value }) {
this.setPreference({ path: `simple.${path}`, value })
this.pushSyncConfig()
},
unsetSimplePrefAndSave({ path }) {
this.unsetPreference({ path: `simple.${path}` })
this.pushSyncConfig()
},
setPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'set' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 1) {
throw new Error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling set on depth > 3 (path: ${path}) is not allowed`,
)
}
if (path.startsWith('collections.')) return value
const definition = path.startsWith('simple.muteFilters')
? { default: {} }
: ROOT_CONFIG_DEFINITIONS[path.split('.')[1]]
const finalValue = validateSetting({
path,
value,
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
if (finalValue !== undefined) set(this.prefsStorage, path, finalValue)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() },
]
this.dirty = true
},
unsetPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'unset' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 2) {
throw new Error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling unset on depth > 3 (path: ${path}) is not allowed`,
)
}
unset(this.prefsStorage, path)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'unset', path, args: [], timestamp: Date.now() },
]
this.dirty = true
},
addCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}'`,
)
}
if (path.startsWith('collections')) {
const collection = new Set(get(this.prefsStorage, path))
collection.add(value)
set(this.prefsStorage, path, [...collection])
}
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'addToCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
removeCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const { _key } = value
if (path.startsWith('collection')) {
const collection = new Set(get(this.prefsStorage, path))
collection.delete(value)
set(this.prefsStorage, path, [...collection])
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'removeFromCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
}
},
reorderCollectionPreference({ path, value, movement }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const collection = get(this.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(this.prefsStorage, path, newCollection)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'arrangeCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
updateCache({ username }) {
this.prefsStorage._journal = _mergeJournal(this.prefsStorage._journal)
this.cache = _wrapData(
{
flagStorage: toRaw(this.flagStorage),
prefsStorage: toRaw(this.prefsStorage),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
this.flagStorage.reset = COMMAND_WIPE_JOURNAL_AND_STORAGE
},
async initSyncConfig(userData) {
const live = userData.storage
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
let dirty = false
if (recent === null) {
console.debug(
`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
flagStorage: { ...flagsTemplate },
prefsStorage: cloneDeep(defaultState.prefsStorage),
})
}
recent = recent && (await _doMigrations(recent, this.setPreference))
stale = stale && (await _doMigrations(stale, this.setPreference))
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
dirty = sum(recentData) !== sum(staleData)
console.debug(`Data ${dirty ? 'needs' : "doesn't need"} merging`)
}
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
let totalPrefs
if (dirty) {
// Merge the flags
console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else {
totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
}
totalPrefs = _resetPrefs(totalPrefs, totalFlags)
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
this.cache = recent
this.flagStorage = this.cache.flagStorage
this.prefsStorage = this.cache.prefsStorage
if (!Array.isArray(this.prefsStorage._journal)) {
this.prefsStorage._journal = []
}
// Various migrations
console.debug('Migrating from old config')
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const migratedEntries = new Set(config._syncMigration ?? [])
console.debug(
`Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
)
Object.entries(oldDefaultConfigSync).forEach(([key, value]) => {
const oldValue = config[key]
const defaultValue = value
const migrated = migratedEntries.has(key)
const preferencePath =
key === 'theme3hacks' ? 'simple.underlay' : `simple.${key}`
const present = oldValue !== undefined
const different = !isEqual(oldValue, defaultValue)
const preferenceHandled =
get(this.prefsStorage, preferencePath) !== undefined ||
this.prefsStorage._journal.some(
(entry) =>
entry?.path === preferencePath ||
entry?.path?.startsWith?.(`${preferencePath}.`),
)
if (present && different && !preferenceHandled) {
console.debug(`Migrating config ${key}: ${oldValue}`)
if (key === 'theme3hacks') {
if (!migrated) {
useLocalConfigStore().set({
path: 'fontInterface',
value: oldValue.fonts.interface,
})
useLocalConfigStore().set({
path: 'fontInput',
value: oldValue.fonts.input,
})
useLocalConfigStore().set({
path: 'fontPosts',
value: oldValue.fonts.post,
})
useLocalConfigStore().set({
path: 'fontMonospace',
value: oldValue.fonts.monospace,
})
}
this.setPreference({
path: 'simple.underlay',
value: oldValue.underlay,
})
} else if (key == 'muteWords') {
oldValue.forEach((word, order) => {
const uniqueId = uuidv4()
this.setPreference({
path: 'simple.muteFilters.' + uniqueId,
value: {
type: 'word',
value: word,
name: word,
enabled: true,
expires: null,
hide: false,
order,
},
})
})
} else {
this.setPreference({ path: preferencePath, value: oldValue })
}
migratedEntries.add(key)
needUpload = true
}
})
config._syncMigration = [...migratedEntries]
vuexState.config = config
storage.setItem('vuex-lz', vuexState)
this.dirty = dirty || needUpload
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.pushSyncConfig()
},
pushSyncConfig({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
+ if (!useUsersStore().currentUser) return
this.updateCache({ username: useUsersStore().currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': this.cache } }
updateProfileJSON({
params,
credentials: useOAuthStore().token,
})
},
},
persist: {
afterLoad(state) {
console.debug('Validating persisted state of SyncConfig')
const newState = { ...state }
newState.prefsStorage = newState.prefsStorage || {}
const newEntries = Object.entries(ROOT_CONFIG).map(([path, value]) => {
const definition = ROOT_CONFIG_DEFINITIONS[path]
const finalValue = validateSetting({
path,
value: newState.prefsStorage.simple?.[path],
definition,
throwError: false,
validateObjects: false,
defaultState: ROOT_CONFIG,
})
return finalValue === undefined
? [path, definition.default]
: [path, finalValue]
})
newState.prefsStorage.simple = Object.fromEntries(
newEntries.filter(Boolean),
)
return newState
},
},
})
diff --git a/src/stores/timelines.js b/src/stores/timelines.js
index 8de616c8af..8617b62365 100644
--- a/src/stores/timelines.js
+++ b/src/stores/timelines.js
@@ -1,484 +1,484 @@
import { first, last } from 'lodash-es'
import { defineStore } from 'pinia'
import timelineFetcher from 'src/stores/fetchers/timeline_fetcher.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { TIMELINE_STREAM_MAP, useStreamingStore } from 'src/stores/streaming.js'
const emptyTl = (name, argument = null) => {
const result = {
// Name of the timeline. Useful for debugging and logging
name,
// Order of statuses, important for timelines that
// have different ordering, i.e. bookmarks and favorites
order: [],
// All statuses belonging to the timeline
statusIds: new Set(),
// Statuses shown to user
visibleStatusIds: new Set(),
// Tracked reprööts, used to avoid displaying same reprööt more than once
repeatedToRepeat: new Map(),
repeatToRepeated: new Map(),
ignoredIds: new Set(),
// Number of statuses not shown yet
newStatusCount: 0,
// Pagination
maxId: '',
minId: '',
// Indicates whether timeline receives push updates
streaming: false,
// Indicates whether timeline is SUPPOSED to be fetching
// this is partiualrly useful for when pausing/resuming
// timeline. I.e. whether we need to start fetching again
// if timeline was resumed.
fetching: false,
// Indicates that in recent poll update we've hit more than or
// equal to 20 statuses and most likely missed some statuses
// between polls
reloadNeeded: false,
// Reference to fetcher, used for polling for new statuses and
// manually fetching old statuses
fetcher: null,
// Reference to WS subscriber
socket: null,
// Whether the timeline has been paused - it stops fetching
// (but still receives pushes!)
paused: false,
}
const property = ARGUMENT_MAP[name]
if (property) {
result[property] = argument
}
- if (name === 'dms' || name === 'friends') {
+ if (name === 'friends') {
result.persistent = true
}
return result
}
export const ARGUMENT_MAP = {
tag: 'tag',
list: 'listId',
bookmarks: 'bookmarkFolderId',
quotes: 'statusId',
search: 'query',
user: 'userId',
userPinned: 'userId',
media: 'userId',
favorites: 'userId',
}
const TIMELINES = new Set([
'mentions',
'public',
'user',
'userPinned',
'media',
'favorites',
'publicAndExternal',
'friends',
'tag',
'dms',
'bookmarks',
'list',
'bubble',
'quotes',
'search',
])
export const defaultState = () => {
return Object.fromEntries([...TIMELINES].map((name) => [name, emptyTl(name)]))
}
export const useTimelinesStore = defineStore('timelines', {
state: defaultState,
actions: {
// (De)Initialization stuff
activate(timelineName, argument, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (
timelineName === 'favorites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
) {
console.warn("Instance doesn't support public favorites timeline")
return
}
const property = ARGUMENT_MAP[timelineName]
if (property) {
timeline[property] = argument
}
timeline.fetcher = timelineFetcher(
timeline,
argument,
useOAuthStore().token,
)
this.startFetchingTimeline(timelineName, argument, 'Timeline activated')
const streamName = TIMELINE_STREAM_MAP[timelineName]
if (streamName) {
const et = new EventTarget()
const openHandler = () => this.onStreamConnect(timelineName, argument)
const closeHandler = () =>
this.onStreamDisconnect(timelineName, argument)
const messageHandler = (message) => {
this.onStreamMessage(timelineName, argument, message)
}
et.addEventListener('open', openHandler)
et.addEventListener('close', closeHandler)
et.addEventListener('update', messageHandler)
timeline.socket = {
name: 'timelines',
stream: {
name: streamName,
argument,
},
et,
handlers: {
openHandler,
closeHandler,
messageHandler,
},
}
useStreamingStore().addSubscriber(timeline.socket)
}
},
deactivate(timelineName, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (timeline.fetching) {
this.stopFetchingTimeline(timelineName, 'Timeline deactivation')
}
if (timeline.socket) {
useStreamingStore().removeSubscriber(timeline.socket)
const { openHandler, closeHandler, messageHandler } =
timeline.socket.handlers
timeline.socket.et.removeEventListener('open', openHandler)
timeline.socket.et.removeEventListener('close', closeHandler)
timeline.socket.et.removeEventListener('update', messageHandler)
}
this[timelineName] = emptyTl(timelineName)
},
clearTimeline(timelineName) {
const timeline = this[timelineName]
timeline.order = []
timeline.statusIds = new Set()
timeline.visibleStatusIds = new Set()
timeline.ignoredIds = new Set()
timeline.newStatusCount = 0
timeline.maxId = ''
timeline.minId = ''
timeline.reloadNeeded = false
timeline.fetcher.resetBottomedOut()
},
activatePersistents() {
TIMELINES.forEach((name) => {
if (this[name].persistent) {
this.activate(name, undefined, true)
}
})
},
deactivateAll() {
TIMELINES.forEach((name) => {
try {
this.deactivate(name, true)
} catch (e) {
console.error(`Failed to deactivate timeline ${name}:`, e)
}
})
},
// Pause
pause(name) {
const timeline = this[name]
timeline.paused = true
console.debug('[Timelines] Pausing timeline', name)
if (timeline.fetcher && timeline.fetching) {
timeline.fetcher.stopFetching()
}
},
resume(name) {
const timeline = this[name]
timeline.paused = false
console.debug('[Timelines] Resuming timeline', name)
if (timeline.fetcher && timeline.fetching) {
timeline.fetcher.startFetching()
}
},
pauseAll() {
TIMELINES.forEach((name) => {
try {
this.pause(name)
} catch (e) {
console.error(`[Timelines] Failed to pause timeline ${name}:`, e)
}
})
},
resumeAll() {
TIMELINES.forEach((name) => {
try {
this.resume(name)
} catch (e) {
console.error(`[Timelines] Failed to pause timeline ${name}:`, e)
}
})
},
// Update stuff
addStatusesToTimeline(
timelineName,
argument,
{
statuses,
repeats = [],
showImmediately = false,
noIdUpdate = false,
pagination = {},
older = false,
},
) {
if (statuses.length === 0) return
const timeline = this[timelineName]
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
// Same can happen with tags etc.
const property = ARGUMENT_MAP[timelineName]
if (property && timeline[property] !== argument) {
return
}
this.populateRepeats(timeline, repeats)
const filtered = statuses.filter((id) => !timeline.statusIds.has(id))
if (older) {
timeline.order.push(...filtered)
} else {
timeline.order.unshift(...filtered)
}
const newStatuses = new Set()
statuses.forEach((statusId) => {
const isNew = !timeline.statusIds.has(statusId)
timeline.statusIds.add(statusId)
if (isNew) {
newStatuses.add(statusId)
}
})
newStatuses.forEach((statusId) => {
const seenBefore = this.checkSeenBefore(timeline, statusId)
if (!seenBefore) {
if (showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
timeline.visibleStatusIds.add(statusId)
} else {
// Just change newStatuscount
timeline.newStatusCount += 1
}
} else {
timeline.ignoredIds.add(statusId)
}
})
if (!noIdUpdate) {
this.updateTimelineExtremes(timeline, pagination)
}
},
onStreamMessage(timelineName, argument, event) {
this.addStatusesToTimeline(timelineName, argument, {
statuses: event.data.map(({ id }) => id),
repeats: event.data
.filter(({ retweeted_status }) => Boolean(retweeted_status))
.map(({ id, retweeted_status: { id: repeatedId } }) => [
id,
repeatedId,
]),
})
},
// Reprööt handling
populateRepeats(timeline, repeats) {
// Starting from oldest
;[...repeats].reverse().forEach(([repeatId, repeatedId]) => {
timeline.repeatToRepeated.set(repeatId, repeatedId)
const knownRepeats =
timeline.repeatedToRepeat.get(repeatedId) ?? new Set()
knownRepeats.add(repeatId)
timeline.repeatedToRepeat.set(repeatedId, knownRepeats)
})
},
checkSeenBefore(timeline, statusId) {
// Check if this is a reprööt
const repeatedStatusId = timeline.repeatToRepeated.get(statusId)
// Non-reprööts are never seen before
if (!repeatedStatusId) return false
// We've seen this status already directly
if (timeline.statusIds.has(repeatedStatusId)) return true
// Check for reprööts
const knownRepeats = timeline.repeatedToRepeat.get(repeatedStatusId)
// If it's the only reprööt then we've never seen post before
if (knownRepeats.size === 1) return false
// If we're working on oldest known reprööt then we've never seen it before
return knownRepeats.values().next().value !== statusId
},
// Poll & Push
onStreamConnect(timeline) {
console.debug('[Timelines] Stream connected', timeline)
this[timeline].streaming = true
this.stopFetchingTimeline(timeline, 'Socket connected')
},
onStreamDisconnect(timeline, argument) {
this[timeline].streaming = false
this.startFetchingTimeline(timeline, argument, 'Socket disconnected')
},
startFetchingTimeline(timelineName, argument, reason) {
const timeline = this[timelineName]
if (timeline.paused) {
console.debug(
'[Timelines] NOT Starting timeline fetcher because it is paused',
timelineName,
argument,
'Original Reason:',
reason,
)
return
}
console.debug(
'[Timelines] Starting timeline fetcher',
timelineName,
argument,
'Reason:',
reason,
)
timeline.fetcher.startFetching()
timeline.fetching = true
},
stopFetchingTimeline(timelineName, reason) {
const timeline = this[timelineName]
if (timeline.fetcher === null) {
console.debug(
'[Timelines] Already inactive timeline',
timelineName,
'Reason:',
reason,
)
return
} else if (timeline.paused) {
console.debug(
'[Timelines] Deactivating paused timeline',
timelineName,
'Reason:',
reason,
)
timeline.fetching = false
} else {
timeline.fetcher.stopFetching()
console.debug(
'[Timelines] Stopped fetching timeline',
timelineName,
'Reason:',
reason,
)
timeline.fetching = false
}
},
// Queues & Timeline manip
updateTimelineExtremes(timeline, pagination = {}, force = false) {
// Can't use Math.min/max because it doesn't work with string (duh)
const minNew = pagination.maxId ?? last(timeline.order) ?? ''
const maxNew = pagination.minId ?? first(timeline.order) ?? ''
const newer = maxNew > timeline.maxId
const older = minNew < timeline.minId
if (force || newer || timeline.maxId === '') {
timeline.maxId = maxNew
}
if (force || older || timeline.minId === '') {
timeline.minId = minNew
}
this.syncOrder(timeline)
},
showNewStatuses(timelineName) {
const timeline = this[timelineName]
timeline.newStatusCount = 0
timeline.order = timeline.order.slice(0, 50)
timeline.statusIds = new Set([...timeline.order])
// Reprööts CAN be present in order but some must be ignored
timeline.visibleStatusIds = new Set([
...timeline.order.filter((id) => !timeline.ignoredIds.has(id)),
])
this.updateTimelineExtremes(timeline, {}, true)
timeline.fetcher.resetBottomedOut()
},
syncOrder(timeline) {
timeline.order = timeline.order.filter((id) => timeline.statusIds.has(id))
},
requireReload(timeline, id) {
this[timeline].reloadNeeded = true
},
requireReloadAll() {
TIMELINES.forEach((timelineName) => {
const timeline = this[timelineName]
timeline.reloadNeeded = true
})
},
// Misc
wipeStatuses(ids) {
TIMELINES.forEach((timelineName) => {
const timeline = this[timelineName]
ids.forEach((id) => {
timeline.statusIds.delete(id)
timeline.visibleStatusIds.delete(id)
})
this.syncOrder(timeline)
})
},
},
})
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Oct 30, 2:06 PM (20 h, 25 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1800189
Default Alt Text
(116 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline