Make pinning and unpinning directly modify state (#37831)

This commit is contained in:
Echo 2026-02-11 17:58:00 +01:00 committed by GitHub
parent 9129f98776
commit ca9966ce2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 105 additions and 59 deletions

View File

@ -6,7 +6,10 @@ import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer'; import { importFetchedAccounts, importFetchedStatus } from './importer';
import { unreblog, reblog } from './interactions_typed'; import { unreblog, reblog } from './interactions_typed';
import { openModal } from './modal'; 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_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; 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 => { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(pinSuccess(status)); dispatch(pinSuccess(status));
dispatch(timelineExpandPinnedFromStatus(status)); dispatch(insertPinnedStatusIntoTimelines(status));
}).catch(error => { }).catch(error => {
dispatch(pinFail(status, error)); dispatch(pinFail(status, error));
}); });
@ -408,7 +411,7 @@ export function unpin (status) {
api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(unpinSuccess(status)); dispatch(unpinSuccess(status));
dispatch(timelineExpandPinnedFromStatus(status)); dispatch(removePinnedStatusFromTimelines(status));
}).catch(error => { }).catch(error => {
dispatch(unpinFail(status, error)); dispatch(unpinFail(status, error));
}); });

View File

@ -7,8 +7,8 @@ import type { Status } from '../models/status';
import { createAppThunk } from '../store/typed_functions'; import { createAppThunk } from '../store/typed_functions';
import { import {
expandAccountFeaturedTimeline,
expandTimeline, expandTimeline,
insertIntoTimeline,
TIMELINE_NON_STATUS_MARKERS, TIMELINE_NON_STATUS_MARKERS,
} from './timelines'; } from './timelines';
@ -173,9 +173,13 @@ export function parseTimelineKey(key: string): TimelineParams | null {
return null; return null;
} }
export function isTimelineKeyPinned(key: string) { export function isTimelineKeyPinned(key: string, accountId?: string) {
const parsedKey = parseTimelineKey(key); 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) { export function isNonStatusId(value: unknown) {
@ -199,52 +203,71 @@ export const timelineDelete = createAction<{
reblogOf: string | null; reblogOf: string | null;
}>('timelines/delete'); }>('timelines/delete');
export const timelineExpandPinnedFromStatus = createAppThunk( export const timelineDeleteStatus = createAction<{
statusId: string;
timelineKey: string;
}>('timelines/deleteStatus');
export const insertPinnedStatusIntoTimelines = createAppThunk(
(status: Status, { dispatch, getState }) => { (status: Status, { dispatch, getState }) => {
const accountId = status.getIn(['account', 'id']) as string; const currentAccountId = getState().meta.get('me', null) as string | null;
if (!accountId) { if (!currentAccountId) {
return; 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<ImmutableMap<'name', string>> // We only care about the tag name.
| undefined
)
?.map((tag) => tag.get('name') as string)
.toArray() ?? [];
const timelines = getState().timelines as ImmutableMap<string, unknown>; const timelines = getState().timelines as ImmutableMap<string, unknown>;
if (!timelines.some((_, key) => key.startsWith(`account:${accountId}:`))) { const accountTimelines = timelines.filter((_, key) => {
return; if (!key.startsWith(`account:${currentAccountId}:`)) {
} return false;
}
void dispatch( const parsed = parseTimelineKey(key);
expandTimelineByParams({ const isPinned = parsed?.type === 'account' && parsed.pinned;
type: 'account', if (!isPinned) {
userId: accountId, return false;
pinned: true,
}),
);
void dispatch(expandAccountFeaturedTimeline(accountId));
// Iterate over tags and clear those too.
const tags = status.get('tags') as
| ImmutableList<ImmutableMap<'name', string>> // We only care about the tag name.
| undefined;
if (!tags) {
return;
}
tags.forEach((tag) => {
const tagName = tag.get('name');
if (!tagName) {
return;
} }
void dispatch( return !parsed.tagged || tags.includes(parsed.tagged);
expandTimelineByParams({ });
type: 'account',
userId: accountId, accountTimelines.forEach((_, key) => {
pinned: true, dispatch(insertIntoTimeline(key, status.get('id') as string, 0));
tagged: tagName, });
}), },
); );
void dispatch(
expandAccountFeaturedTimeline(accountId, { tagged: tagName }), 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<string>>
>;
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 }));
}
}); });
}, },
); );

View File

@ -20,7 +20,12 @@ import {
TIMELINE_GAP, TIMELINE_GAP,
disconnectTimeline, disconnectTimeline,
} from '../actions/timelines'; } 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'; import { compareId } from '../compare_id';
const initialState = ImmutableMap(); const initialState = ImmutableMap();
@ -145,6 +150,11 @@ const deleteStatus = (state, id, references, exclude_account = null) => {
return state; 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) => { const clearTimeline = (state, timeline) => {
return state.set(timeline, initialTimeline); 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); return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems);
case TIMELINE_UPDATE: case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems); 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: case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline); 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: case TIMELINE_SCROLL_TOP:
return updateTop(state, action.timeline, action.top); return updateTop(state, action.timeline, action.top);
case TIMELINE_CONNECT: case TIMELINE_CONNECT:
return state.update(action.timeline, initialTimeline, map => reconnectTimeline(map, action.usePendingItems)); 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: case TIMELINE_MARK_AS_PARTIAL:
return state.update( return state.update(
action.timeline, action.timeline,
@ -238,6 +235,29 @@ export default function timelines(state = initialState, action) {
}) })
); );
default: 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; return state;
} }
} }