diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 437f597314d..60df9abc530 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -6,7 +6,10 @@ import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import { unreblog, reblog } from './interactions_typed'; import { openModal } from './modal'; -import { timelineExpandPinnedFromStatus } from './timelines_typed'; +import { + insertPinnedStatusIntoTimelines, + removePinnedStatusFromTimelines, +} from './timelines_typed'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -369,7 +372,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)); + dispatch(insertPinnedStatusIntoTimelines(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -408,7 +411,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)); + dispatch(removePinnedStatusFromTimelines(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index f07b1274e2f..98165617790 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -7,8 +7,8 @@ import type { Status } from '../models/status'; import { createAppThunk } from '../store/typed_functions'; import { - expandAccountFeaturedTimeline, expandTimeline, + insertIntoTimeline, TIMELINE_NON_STATUS_MARKERS, } from './timelines'; @@ -173,9 +173,13 @@ export function parseTimelineKey(key: string): TimelineParams | null { return null; } -export function isTimelineKeyPinned(key: string) { +export function isTimelineKeyPinned(key: string, accountId?: string) { const parsedKey = parseTimelineKey(key); - return parsedKey?.type === 'account' && parsedKey.pinned; + const isPinned = parsedKey?.type === 'account' && parsedKey.pinned; + if (!accountId || !isPinned) { + return isPinned; + } + return parsedKey.userId === accountId; } export function isNonStatusId(value: unknown) { @@ -199,52 +203,71 @@ export const timelineDelete = createAction<{ reblogOf: string | null; }>('timelines/delete'); -export const timelineExpandPinnedFromStatus = createAppThunk( +export const timelineDeleteStatus = createAction<{ + statusId: string; + timelineKey: string; +}>('timelines/deleteStatus'); + +export const insertPinnedStatusIntoTimelines = createAppThunk( (status: Status, { dispatch, getState }) => { - const accountId = status.getIn(['account', 'id']) as string; - if (!accountId) { + const currentAccountId = getState().meta.get('me', null) as string | null; + if (!currentAccountId) { return; } - // Verify that any of the relevant timelines are actually expanded before dispatching, to avoid unnecessary API calls. + const tags = + ( + status.get('tags') as + | ImmutableList> // We only care about the tag name. + | undefined + ) + ?.map((tag) => tag.get('name') as string) + .toArray() ?? []; + 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; + const accountTimelines = timelines.filter((_, key) => { + if (!key.startsWith(`account:${currentAccountId}:`)) { + return false; + } + const parsed = parseTimelineKey(key); + const isPinned = parsed?.type === 'account' && parsed.pinned; + if (!isPinned) { + return false; } - void dispatch( - expandTimelineByParams({ - type: 'account', - userId: accountId, - pinned: true, - tagged: tagName, - }), - ); - void dispatch( - expandAccountFeaturedTimeline(accountId, { tagged: tagName }), - ); + return !parsed.tagged || tags.includes(parsed.tagged); + }); + + accountTimelines.forEach((_, key) => { + dispatch(insertIntoTimeline(key, status.get('id') as string, 0)); + }); + }, +); + +export const removePinnedStatusFromTimelines = createAppThunk( + (status: Status, { dispatch, getState }) => { + const currentAccountId = getState().meta.get('me', null) as string | null; + if (!currentAccountId) { + return; + } + + const statusId = status.get('id') as string; + const timelines = getState().timelines as ImmutableMap< + string, + ImmutableMap<'items' | 'pendingItems', ImmutableList> + >; + + timelines.forEach((timeline, key) => { + if (!isTimelineKeyPinned(key, currentAccountId)) { + return; + } + + if ( + timeline.get('items')?.includes(statusId) || + timeline.get('pendingItems')?.includes(statusId) + ) { + dispatch(timelineDeleteStatus({ statusId, timelineKey: key })); + } }); }, ); diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index df0a26bf8d0..ae9ea345820 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -20,7 +20,12 @@ import { TIMELINE_GAP, disconnectTimeline, } from '../actions/timelines'; -import { timelineDelete, isTimelineKeyPinned, isNonStatusId } from '../actions/timelines_typed'; +import { + timelineDelete, + timelineDeleteStatus, + isTimelineKeyPinned, + isNonStatusId, +} from '../actions/timelines_typed'; import { compareId } from '../compare_id'; const initialState = ImmutableMap(); @@ -145,6 +150,11 @@ const deleteStatus = (state, id, references, exclude_account = null) => { return state; }; +const deleteStatusFromTimeline = (state, statusId, timelineKey) => { + const helper = list => list.filterNot((status) => status === statusId); + return state.updateIn([timelineKey, 'items'], helper).updateIn([timelineKey, 'pendingItems'], helper); +} + const clearTimeline = (state, timeline) => { return state.set(timeline, initialTimeline); }; @@ -200,25 +210,12 @@ export default function timelines(state = initialState, action) { return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems); case TIMELINE_UPDATE: return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems); - case timelineDelete.type: - return deleteStatus(state, action.payload.statusId, action.payload.references, action.payload.reblogOf); case TIMELINE_CLEAR: return clearTimeline(state, action.timeline); - case blockAccountSuccess.type: - case muteAccountSuccess.type: - return filterTimelines(state, action.payload.relationship, action.payload.statuses); - case unfollowAccountSuccess.type: - return filterTimeline('home', state, action.payload.relationship, action.payload.statuses); case TIMELINE_SCROLL_TOP: return updateTop(state, action.timeline, action.top); case TIMELINE_CONNECT: return state.update(action.timeline, initialTimeline, map => reconnectTimeline(map, action.usePendingItems)); - case disconnectTimeline.type: - return state.update( - action.payload.timeline, - initialTimeline, - map => map.set('online', false).update(action.payload.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(TIMELINE_GAP) : items), - ); case TIMELINE_MARK_AS_PARTIAL: return state.update( action.timeline, @@ -238,6 +235,29 @@ export default function timelines(state = initialState, action) { }) ); default: + if (timelineDelete.match(action)) { + return deleteStatus(state, action.payload.statusId, action.payload.references, action.payload.reblogOf); + } else if (timelineDeleteStatus.match(action)) { + return deleteStatusFromTimeline(state, action.payload.statusId, action.payload.timelineKey); + } else if (blockAccountSuccess.match(action) || muteAccountSuccess.match(action)) { + return filterTimelines(state, action.payload.relationship, action.payload.statuses); + } else if (unfollowAccountSuccess.match(action)) { + return filterTimeline('home', state, action.payload.relationship, action.payload.statuses); + } else if (disconnectTimeline.match(action)) { + return state.update( + action.payload.timeline, + initialTimeline, + (map) => map.set('online', false).update( + action.payload.usePendingItems + ? 'pendingItems' + : 'items', + items => items.first() + ? items.unshift(TIMELINE_GAP) + : items + ), + ); + } + return state; } }