diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 92142d782c..38694d83e5 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -6,6 +6,7 @@ import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import { unreblog, reblog } from './interactions_typed'; import { openModal } from './modal'; +import { timelineExpandPinnedFromStatus } from './timelines_typed'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -368,6 +369,7 @@ export function pin(status) { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(pinSuccess(status)); + dispatch(timelineExpandPinnedFromStatus(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -406,6 +408,7 @@ export function unpin (status) { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unpinSuccess(status)); + dispatch(timelineExpandPinnedFromStatus(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); diff --git a/app/javascript/flavours/glitch/actions/timelines.test.ts b/app/javascript/flavours/glitch/actions/timelines.test.ts index e7f4198cde..239692dd34 100644 --- a/app/javascript/flavours/glitch/actions/timelines.test.ts +++ b/app/javascript/flavours/glitch/actions/timelines.test.ts @@ -57,4 +57,29 @@ describe('parseTimelineKey', () => { tagged: 'nature', }); }); + + test('parses legacy account timeline key with pinned correctly', () => { + const params = parseTimelineKey('account:789:pinned:nature'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: false, + pinned: true, + tagged: 'nature', + }); + }); + + test('parses legacy account timeline key with media correctly', () => { + const params = parseTimelineKey('account:789:media'); + expect(params).toEqual({ + type: 'account', + userId: '789', + replies: false, + boosts: false, + media: true, + pinned: false, + }); + }); }); diff --git a/app/javascript/flavours/glitch/actions/timelines_typed.ts b/app/javascript/flavours/glitch/actions/timelines_typed.ts index 8d4c7a0178..dd8f160b61 100644 --- a/app/javascript/flavours/glitch/actions/timelines_typed.ts +++ b/app/javascript/flavours/glitch/actions/timelines_typed.ts @@ -1,10 +1,16 @@ import { createAction } from '@reduxjs/toolkit'; +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state'; +import type { Status } from '../models/status'; import { createAppThunk } from '../store/typed_functions'; -import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines'; +import { + expandAccountFeaturedTimeline, + expandTimeline, + TIMELINE_NON_STATUS_MARKERS, +} from './timelines'; export const expandTimelineByKey = createAppThunk( (args: { key: string; maxId?: number }, { dispatch }) => { @@ -119,8 +125,25 @@ export function parseTimelineKey(key: string): TimelineParams | null { type: 'account', userId, tagged: segments[3], + pinned: false, + boosts: false, + replies: false, + media: false, }; + // Handle legacy keys. + const flagsSegment = segments[2]; + if (!flagsSegment || !/^[01]{4}$/.test(flagsSegment)) { + if (flagsSegment === 'pinned') { + parsed.pinned = true; + } else if (flagsSegment === 'with_replies') { + parsed.replies = true; + } else if (flagsSegment === 'media') { + parsed.media = true; + } + return parsed; + } + const view = segments[2]?.split('') ?? []; for (let i = 0; i < view.length; i++) { const flagName = ACCOUNT_FILTERS[i]; @@ -150,6 +173,11 @@ export function parseTimelineKey(key: string): TimelineParams | null { return null; } +export function isTimelineKeyPinned(key: string) { + const parsedKey = parseTimelineKey(key); + return parsedKey?.type === 'account' && parsedKey.pinned; +} + export function isNonStatusId(value: unknown) { return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); } @@ -170,3 +198,53 @@ export const timelineDelete = createAction<{ references: string[]; reblogOf: string | null; }>('timelines/delete'); + +export const timelineExpandPinnedFromStatus = createAppThunk( + (status: Status, { dispatch, getState }) => { + const accountId = status.getIn(['account', 'id']) as string; + if (!accountId) { + return; + } + + // Verify that any of the relevant timelines are actually expanded before dispatching, to avoid unnecessary API calls. + const timelines = getState().timelines as ImmutableMap; + if (!timelines.some((_, key) => key.startsWith(`account:${accountId}:`))) { + return; + } + + void dispatch( + expandTimelineByParams({ + type: 'account', + userId: accountId, + pinned: true, + }), + ); + void dispatch(expandAccountFeaturedTimeline(accountId)); + + // Iterate over tags and clear those too. + const tags = status.get('tags') as + | ImmutableList> // We only care about the tag name. + | undefined; + if (!tags) { + return; + } + tags.forEach((tag) => { + const tagName = tag.get('name'); + if (!tagName) { + return; + } + + void dispatch( + expandTimelineByParams({ + type: 'account', + userId: accountId, + pinned: true, + tagged: tagName, + }), + ); + void dispatch( + expandAccountFeaturedTimeline(accountId, { tagged: tagName }), + ); + }); + }, +); diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js index 3a7da91651..d50df05344 100644 --- a/app/javascript/flavours/glitch/reducers/timelines.js +++ b/app/javascript/flavours/glitch/reducers/timelines.js @@ -1,6 +1,5 @@ import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; -import { timelineDelete, isNonStatusId } from 'flavours/glitch/actions/timelines_typed'; import { blockAccountSuccess, @@ -21,6 +20,7 @@ import { TIMELINE_GAP, disconnectTimeline, } from '../actions/timelines'; +import { timelineDelete, isTimelineKeyPinned, isNonStatusId } from '../actions/timelines_typed'; import { compareId } from '../compare_id'; const initialState = ImmutableMap(); @@ -50,7 +50,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is if (!next && !isLoadingRecent) mMap.set('hasMore', false); - if (timeline.endsWith(':pinned')) { + if (isTimelineKeyPinned(timeline)) { mMap.set('items', statuses.map(status => status.get('id'))); } else if (!statuses.isEmpty()) { usePendingItems = isLoadingRecent && (usePendingItems || !mMap.get('pendingItems').isEmpty());