diff --git a/changelog.d/preview_posting.fix b/changelog.d/preview_posting.fix new file mode 100644 index 000000000..1a35dbeba --- /dev/null +++ b/changelog.d/preview_posting.fix @@ -0,0 +1 @@ +Fix posting not working when using preview diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 1cea48670..4fb6f4746 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -720,6 +720,10 @@ const PostStatusForm = { }) .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 () { diff --git a/src/components/status/status.js b/src/components/status/status.js index a53812da1..43b0f4806 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -152,8 +152,7 @@ const Status = { }, showReasonMutedThread() { return ( - (this.mainStatus.thread_muted || - this.smainSatus.reblog?.thread_muted) && + (this.mainStatus.thread_muted || this.mainSatus.reblog?.thread_muted) && !this.inConversation ) }, diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index 3c7e33060..ed9519e06 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -31,6 +31,7 @@ const Timeline = { embedded: Boolean, inProfile: Boolean, skipPinned: Boolean, + hideEmpty: Boolean, }, data() { return { @@ -106,7 +107,9 @@ const Timeline = { const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80)) const min = Math.max(0, this.virtualScrollIndex - statusesPerSide) const max = Math.min(amount, this.virtualScrollIndex + statusesPerSide) - return new Set(this.timeline.order.slice(min, max)) + return new Set( + this.filteredVisibleStatuses.slice(min, max).map(({ id }) => id), + ) }, virtualScrollingEnabled() { return useMergedConfigStore().mergedConfig.virtualScrolling diff --git a/src/components/timeline/timeline.scss b/src/components/timeline/timeline.scss index 2dd66328d..984129bf4 100644 --- a/src/components/timeline/timeline.scss +++ b/src/components/timeline/timeline.scss @@ -4,6 +4,14 @@ backdrop-filter: none; } + .timeline-placeholder { + background: var(--background); + display: flex; + place-items: center; + place-content: center; + padding: 5em; + } + .alert-badge { font-size: 0.75em; line-height: 1; diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 1435bba48..d3c3eced2 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -40,7 +40,7 @@
+
- {{ $t('timeline.no_statuses') }} -
-
{{ $t('timeline.no_more_statuses') }}
-
+
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 9a987cd09..43d91b8e5 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -30,6 +30,7 @@ :timeline-ref="{ name: 'userPinned', argument: userId }" embedded in-profile + hide-empty /> { const numStatusesBeforeFetch = timeline.statusIds.size - if (bottomedOut.value) return + if (older && bottomedOut.value) return return fetchTimeline(args) - .then(({ data: statuses, pagination, timestamp }) => { - if (!older && statuses.length >= 20 && numStatusesBeforeFetch > 0) { + .then(({ data, pagination, timestamp }) => { + // No statuses for timeline, ever. + if (timeline.order.length === 0 && data.length === 0) { + bottomedOut.value = true + } + if (!older && data.length >= 20 && numStatusesBeforeFetch > 0) { useTimelinesStore().requireReload(timeline.name) } - if (older && statuses.length === 0) { + if (older && data.length === 0) { bottomedOut.value = true } - const processed = useStatusesStore() - .addNewStatuses({ statuses, timestamp }) - .map(({ id }) => id) + const processed = useStatusesStore().addNewStatuses({ + statuses: data, + timestamp, + }) + + const statuses = processed.map(({ id }) => id) + + const repeats = processed + .filter(({ retweeted_status }) => Boolean(retweeted_status)) + .map(({ id, retweeted_status: { id: repeatedId } }) => [ + id, + repeatedId, + ]) useTimelinesStore().addStatusesToTimeline(timeline.name, argument, { - statuses: processed, + statuses, + repeats, showImmediately, older, pagination, diff --git a/src/stores/timelines.js b/src/stores/timelines.js index 26223e69d..ed4ecfaec 100644 --- a/src/stores/timelines.js +++ b/src/stores/timelines.js @@ -21,6 +21,11 @@ const emptyTl = (name, argument = null) => { // 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, @@ -188,6 +193,7 @@ export const useTimelinesStore = defineStore('timelines', { timeline.order = [] timeline.statusIds = new Set() timeline.visibleStatusIds = new Set() + timeline.ignoredIds = new Set() timeline.newStatusCount = 0 timeline.maxId = '' timeline.minId = '' @@ -252,6 +258,7 @@ export const useTimelinesStore = defineStore('timelines', { argument, { statuses, + repeats = [], showImmediately = false, noIdUpdate = false, pagination = {}, @@ -261,6 +268,8 @@ export const useTimelinesStore = defineStore('timelines', { if (statuses.length === 0) return const timeline = this[timelineName] + this.populateRepeats(timeline, repeats) + // 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 @@ -287,13 +296,18 @@ export const useTimelinesStore = defineStore('timelines', { timeline.statusIds.add(statusId) if (isNew) { - if (showImmediately) { - // Add it directly to the visibleStatuses, don't change - // newStatusCount - timeline.visibleStatusIds.add(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 { - // Just change newStatuscount - timeline.newStatusCount += 1 + timeline.ignoredIds.add(statusId) } } }) @@ -301,9 +315,43 @@ export const useTimelinesStore = defineStore('timelines', { onStreamMessage(timeline, argument, event) { this.addStatusesToTimeline(timeline, 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 first(knownRepeats) !== statusId + }, + // Poll & Push onStreamConnect(timeline) { console.debug('[Timelines] Stream connected', timeline) @@ -391,7 +439,11 @@ export const useTimelinesStore = defineStore('timelines', { timeline.newStatusCount = 0 timeline.order = timeline.order.slice(0, 50) timeline.statusIds = new Set([...timeline.order]) - timeline.visibleStatusIds = 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) }, syncOrder(timeline) { diff --git a/test/unit/specs/stores/timelines.spec.js b/test/unit/specs/stores/timelines.spec.js index cfc84ea3d..2b4704052 100644 --- a/test/unit/specs/stores/timelines.spec.js +++ b/test/unit/specs/stores/timelines.spec.js @@ -122,6 +122,7 @@ describe('Timelines store', () => { store.activate('list', '1') store.addStatusesToTimeline('list', '1', { statuses, + repeats: [], pagination: { minId: '1', maxId: '3' }, }) @@ -140,10 +141,12 @@ describe('Timelines store', () => { store.activate('list', '1') store.addStatusesToTimeline('list', '1', { statuses, + repeats: [], pagination: { minId: '1', maxId: '3' }, }) store.addStatusesToTimeline('list', '1', { statuses, + repeats: [], pagination: { minId: '1', maxId: '3' }, }) @@ -162,6 +165,7 @@ describe('Timelines store', () => { store.activate('list', '1') store.addStatusesToTimeline('list', '1', { statuses, + repeats: [], showImmediately: true, pagination: { minId: '1', maxId: '3' }, }) @@ -181,6 +185,7 @@ describe('Timelines store', () => { store.activate('list', '1') store.addStatusesToTimeline('list', '1', { statuses, + repeats: [], noIdUpdate: true, pagination: { minId: '1', maxId: '3' }, }) @@ -200,6 +205,7 @@ describe('Timelines store', () => { store.activate('list', '1') store.addStatusesToTimeline('list', '2', { statuses, + repeats: [], noIdUpdate: true, pagination: { minId: '1', maxId: '3' }, }) @@ -220,10 +226,12 @@ describe('Timelines store', () => { store.activate('list', '1') store.addStatusesToTimeline('list', '1', { statuses: statuses1, + repeats: [], pagination: { minId: '3', maxId: '1' }, }) store.addStatusesToTimeline('list', '1', { statuses: statuses2, + repeats: [], pagination: { minId: '6', maxId: '4' }, }) @@ -244,10 +252,12 @@ describe('Timelines store', () => { store.activate('list', '1') store.addStatusesToTimeline('list', '1', { statuses: statuses1, + repeats: [], pagination: { minId: '6', maxId: '4' }, }) store.addStatusesToTimeline('list', '1', { statuses: statuses2, + repeats: [], pagination: { minId: '3', maxId: '1' }, older: true, }) @@ -260,6 +270,157 @@ describe('Timelines store', () => { expect(store.list).to.have.property('maxId', '6') expect(store.list).to.have.property('minId', '1') }) + + describe('repeat de-duplication', () => { + it('handles repeat de-duplication (older)', () => { + const store = useTimelinesStore() + const statuses1 = ['s7s3', 's6s3', 's5', 's4s0', 's3', 's2'] + const statuses2 = ['s1s0', 's0'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s7s3', 's2'], + ['s6s3', 's2'], + ['s4s0', 's0'], + ], + pagination: { minId: 's7s3', maxId: 's2' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s5', 's4s0', 's3', 's2']), + ) + expect(store.list).to.have.property('maxId', 's7s3') + expect(store.list).to.have.property('minId', 's2') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [['s1s0', 's0']], + pagination: { minId: 's1', maxId: 's0' }, + showImmediately: true, + older: true, + }) + + const newOrder = [...statuses1, ...statuses2] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s5', 's4s0', 's3', 's2', 's0']), + ) + expect(store.list).to.have.property('maxId', 's7s3') + expect(store.list).to.have.property('minId', 's0') + }) + + it('handles repeat de-duplication(reverse)', () => { + const store = useTimelinesStore() + const statuses1 = ['s6s2', 's5s2', 's4', 's3s0', 's2', 's1'] + const statuses2 = ['sAs0', 's9s1', 's8', 's7s2'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s6s2', 's2'], + ['s5s2', 's2'], + ['s3s0', 's0'], + ], + pagination: { minId: 's6s2', maxId: 's1' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + expect(store.list).to.have.property('maxId', 's6s2') + expect(store.list).to.have.property('minId', 's1') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [ + ['sAs0', 's0'], + ['s9s1', 's1'], + ['s7s2', 's2'], + ], + pagination: { minId: 'sAs0', maxId: 's7s2' }, + showImmediately: true, + }) + + const newOrder = [...statuses2, ...statuses1] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.visibleStatusIds).to.eql( + new Set([ + 's4', + 's3s0', + 's2', + 's1', + // Newer + 's8', + ]), + ) + expect(store.list).to.have.property('maxId', 'sAs0') + expect(store.list).to.have.property('minId', 's1') + }) + + it('showNewStatuses follows de-duplication rules', () => { + const store = useTimelinesStore() + const statuses1 = ['s6s2', 's5s2', 's4', 's3s0', 's2', 's1'] + const statuses2 = ['sAs0', 's9s1', 's8', 's7s2'] + + store.activate('list', '1') + store.addStatusesToTimeline('list', '1', { + statuses: statuses1, + repeats: [ + ['s6s2', 's2'], + ['s5s2', 's2'], + ['s3s0', 's0'], + ], + pagination: { minId: 's6s2', maxId: 's1' }, + showImmediately: true, + }) + expect(store.list.order).to.eql(statuses1) + expect(store.list.statusIds).to.eql(new Set(statuses1)) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + expect(store.list).to.have.property('maxId', 's6s2') + expect(store.list).to.have.property('minId', 's1') + + store.addStatusesToTimeline('list', '1', { + statuses: statuses2, + repeats: [ + ['sAs0', 's0'], + ['s9s1', 's1'], + ['s7s2', 's2'], + ], + pagination: { minId: 'sAs0', maxId: 's7s2' }, + }) + + const newOrder = [...statuses2, ...statuses1] + expect(store.list.order).to.eql(newOrder) + expect(store.list.statusIds).to.eql(new Set(newOrder)) + expect(store.list.newStatusCount).to.eql(1) + expect(store.list.visibleStatusIds).to.eql( + new Set(['s4', 's3s0', 's2', 's1']), + ) + + store.showNewStatuses('list') + expect(store.list.visibleStatusIds).to.eql( + new Set([ + 's4', + 's3s0', + 's2', + 's1', + // Newer + 's8', + ]), + ) + }) + }) }) describe('showNewStatuses', () => { @@ -270,6 +431,7 @@ describe('Timelines store', () => { store.activate('public') store.addStatusesToTimeline('public', undefined, { statuses, + repeats: [], pagination: { minId: '1', maxId: '3' }, }) @@ -280,6 +442,27 @@ describe('Timelines store', () => { expect(store.public.visibleStatusIds).to.eql(new Set(statuses)) expect(store.public.newStatusCount).to.equal(0) }) + + it('limits shown items to 50 for better performance', () => { + const store = useTimelinesStore() + const statuses = new Array(100).fill().map((_, index) => 's' + index) + + store.activate('public') + store.addStatusesToTimeline('public', undefined, { + statuses, + repeats: [], + pagination: { minId: '1', maxId: '3' }, + }) + + expect(store.public.statusIds).to.eql(new Set(statuses)) + expect(store.public.visibleStatusIds).to.eql(new Set()) + expect(store.public.newStatusCount).to.equal(100) + store.showNewStatuses('public') + expect(store.public.visibleStatusIds).to.eql( + new Set(statuses.slice(0, 50)), + ) + expect(store.public.newStatusCount).to.equal(0) + }) }) describe('wipeStatuses', () => { @@ -290,9 +473,11 @@ describe('Timelines store', () => { store.activate('public') store.addStatusesToTimeline('public', undefined, { statuses: ['1', '2', '3', '0'], + repeats: [], }) store.addStatusesToTimeline('friends', undefined, { statuses: ['5', '0', '9', '1'], + repeats: [], }) store.wipeStatuses(['0']) expect(store.friends.statusIds).to.not.have.members('0')