Alexander Zinchuk ca8260c309 Profile: Display gift and story collections (#6190)
Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
2025-09-09 21:10:31 +02:00

596 lines
16 KiB
TypeScript

import type {
ApiPeerStories,
ApiReaction,
ApiStealthMode,
ApiStory,
ApiStoryDeleted,
ApiStorySkipped,
ApiStoryViews,
ApiTypeStory,
ApiTypeStoryView,
} from '../../api/types';
import type { GlobalState, TabArgs } from '../types';
import { isUserId } from '../../util/entities/ids';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { compareFields, unique } from '../../util/iteratees';
import { getServerTime } from '../../util/serverTime';
import { updateReactionCount } from '../helpers';
import {
selectIsChatWithSelf,
selectPeer, selectPeerStories, selectPeerStory, selectTabState, selectUser,
} from '../selectors';
import { updatePeer } from './peers';
import { updateTabState } from './tabs';
export function addStories<T extends GlobalState>(global: T, newStoriesByPeerId: Record<string, ApiPeerStories>): T {
const updatedByPeerId = Object.entries(newStoriesByPeerId).reduce((acc, [peerId, newPeerStories]) => {
if (!acc[peerId]) {
acc[peerId] = newPeerStories;
} else {
acc[peerId].byId = { ...acc[peerId].byId, ...newPeerStories.byId };
acc[peerId].orderedIds = unique(newPeerStories.orderedIds.concat(acc[peerId].orderedIds));
acc[peerId].profileIds = unique(newPeerStories.profileIds.concat(acc[peerId].profileIds)).sort((a, b) => b - a);
acc[peerId].lastUpdatedAt = newPeerStories.lastUpdatedAt;
acc[peerId].lastReadId = newPeerStories.lastReadId;
}
return acc;
}, global.stories.byPeerId);
global = {
...global,
stories: {
...global.stories,
byPeerId: updatedByPeerId,
},
};
return updateOrderedStoriesPeerIds(global, Object.keys(newStoriesByPeerId));
}
export function addStoriesForPeer<T extends GlobalState>(
global: T,
peerId: string,
newStories: Record<number, ApiTypeStory>,
newPinnedIds?: number[],
addToArchive?: boolean,
albumId?: number,
): T {
const {
byId, orderedIds, profileIds, archiveIds, pinnedIds, idsByAlbumId,
} = global.stories.byPeerId[peerId] || {};
const deletedIds = Object.keys(newStories).filter((id) => 'isDeleted' in newStories[Number(id)]).map(Number);
const updatedById = { ...byId, ...newStories };
let updatedOrderedIds = [...(orderedIds || [])];
let updatedArchiveIds = [...(archiveIds || [])];
const updatedProfileIds = unique(
[...(profileIds || [])].concat(Object.values(newStories).reduce((ids, story) => {
if (('isInProfile' in story && story.isInProfile)) {
ids.push(story.id);
}
return ids;
}, [] as number[])),
).sort((a, b) => b - a).filter((storyId) => !deletedIds.includes(storyId));
updatedOrderedIds = unique(Object.entries(newStories).reduce((acc, [storyId, story]) => {
if ('expireDate' in story && story.expireDate && story.expireDate > getServerTime()) {
acc.push(Number(storyId));
}
return acc;
}, updatedOrderedIds)).filter((storyId) => !deletedIds.includes(storyId));
if (addToArchive && selectIsChatWithSelf(global, peerId)) {
updatedArchiveIds = unique(updatedArchiveIds.concat(Object.keys(newStories).map(Number)))
.sort((a, b) => b - a)
.filter((storyId) => !deletedIds.includes(storyId));
}
const updatedIdsByAlbumId = { ...(idsByAlbumId || {}) };
if (albumId !== undefined) {
const newAlbumStoryIds = Object.keys(newStories).map(Number)
.filter((storyId) => !deletedIds.includes(storyId));
const existingAlbumData = updatedIdsByAlbumId[albumId];
const existingIds = existingAlbumData?.ids || [];
updatedIdsByAlbumId[albumId] = {
...updatedIdsByAlbumId[albumId],
ids: unique([...existingIds, ...newAlbumStoryIds]).sort((a, b) => b - a),
};
}
global = {
...global,
stories: {
...global.stories,
byPeerId: {
...global.stories.byPeerId,
[peerId]: {
...global.stories.byPeerId[peerId],
byId: updatedById,
orderedIds: updatedOrderedIds,
profileIds: updatedProfileIds,
pinnedIds: pinnedIds || newPinnedIds,
...(addToArchive && { archiveIds: updatedArchiveIds }),
...(albumId !== undefined && { idsByAlbumId: updatedIdsByAlbumId }),
},
},
},
};
if (selectIsChatWithSelf(global, peerId)
|| selectUser(global, peerId)?.isContact
|| peerId === global.appConfig.storyChangelogUserId) {
global = updatePeerLastUpdatedAt(global, peerId);
global = updateOrderedStoriesPeerIds(global, [peerId]);
}
return global;
}
export function updateStoriesForPeer<T extends GlobalState>(
global: T,
peerId: string,
peerStories: ApiPeerStories,
): T {
return {
...global,
stories: {
...global.stories,
byPeerId: {
...global.stories.byPeerId,
[peerId]: peerStories,
},
},
};
}
export function updatePeerStoriesFullyLoaded<T extends GlobalState>(
global: T,
peerId: string,
isFullyLoaded: boolean,
isArchive?: boolean,
albumId?: number,
): T {
const { byPeerId } = global.stories;
const peerStories = byPeerId[peerId];
if (albumId !== undefined && peerStories?.idsByAlbumId?.[albumId]) {
return {
...global,
stories: {
...global.stories,
byPeerId: {
...byPeerId,
[peerId]: {
...peerStories,
idsByAlbumId: {
...peerStories.idsByAlbumId,
[albumId]: {
...peerStories.idsByAlbumId[albumId],
isFullyLoaded,
},
},
},
},
},
};
}
return {
...global,
stories: {
...global.stories,
byPeerId: {
...global.stories.byPeerId,
[peerId]: {
...global.stories.byPeerId[peerId],
[isArchive ? 'isArchiveFullyLoaded' : 'isFullyLoaded']: isFullyLoaded,
},
},
},
};
}
export function updateLastReadStoryForPeer<T extends GlobalState>(
global: T,
peerId: string,
lastReadId: number,
): T {
const { orderedIds } = selectPeerStories(global, peerId) || {};
if (!orderedIds) {
return global;
}
if (lastReadId >= orderedIds[orderedIds.length - 1]) {
global = updatePeer(global, peerId, {
hasUnreadStories: false,
});
}
return {
...global,
stories: {
...global.stories,
byPeerId: {
...global.stories.byPeerId,
[peerId]: {
...global.stories.byPeerId[peerId],
lastReadId,
},
},
},
};
}
export function updateLastViewedStoryForPeer<T extends GlobalState>(
global: T,
peerId: string,
lastViewedId: number,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const { orderedIds } = selectPeerStories(global, peerId) || {};
if (!orderedIds || !orderedIds.includes(lastViewedId)) {
return global;
}
const { storyViewer } = selectTabState(global, tabId);
return updateTabState(global, {
storyViewer: {
...storyViewer,
lastViewedByPeerId: {
...storyViewer.lastViewedByPeerId,
[peerId]: lastViewedId,
},
},
}, tabId);
}
export function updatePeersWithStories<T extends GlobalState>(
global: T,
storiesByPeerId: Record<string, ApiPeerStories>,
): T {
Object.entries(storiesByPeerId).forEach(([peerId, { lastReadId, orderedIds }]) => {
const peer = selectPeer(global, peerId);
if (!peer) return;
global = updatePeer(global, peerId, {
hasStories: true,
hasUnreadStories: !lastReadId
|| Boolean(lastReadId && lastReadId < (peer.maxStoryId || orderedIds[orderedIds.length - 1])),
});
});
return global;
}
export function updateStoryViews<T extends GlobalState>(
global: T,
storyId: number,
views: ApiTypeStoryView[],
nextOffset?: string,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const tabState = selectTabState(global, tabId);
const { viewModal } = tabState.storyViewer;
const newViews = viewModal?.storyId === storyId && viewModal.views ? [
...viewModal.views,
...views,
] : views;
global = updateStoryViewsLoading(global, false, tabId);
return updateTabState(global, {
storyViewer: {
...tabState.storyViewer,
viewModal: {
...viewModal,
storyId,
views: newViews,
nextOffset,
isLoading: false,
},
},
}, tabId);
}
export function updateStoryViewsLoading<T extends GlobalState>(
global: T,
isLoading: boolean,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const tabState = selectTabState(global, tabId);
const { viewModal } = tabState.storyViewer;
if (!viewModal) return global;
return updateTabState(global, {
storyViewer: {
...tabState.storyViewer,
viewModal: {
...viewModal,
isLoading,
},
},
}, tabId);
}
export function removePeerStory<T extends GlobalState>(
global: T,
peerId: string,
storyId: number,
): T {
const {
orderedIds, profileIds, lastReadId, byId,
} = selectPeerStories(global, peerId) || { orderedIds: [] as number[], profileIds: [] as number[] };
const newOrderedIds = orderedIds.filter((id) => id !== storyId);
const newProfileIds = profileIds.filter((id) => id !== storyId);
const lastStoryId = newOrderedIds.length ? orderedIds[orderedIds.length - 1] : undefined;
const previousStoryId = orderedIds[orderedIds.indexOf(storyId) - 1];
const newLastReadId = lastReadId === storyId ? previousStoryId : lastReadId;
const newById = {
...byId,
[storyId]: { id: storyId, peerId, isDeleted: true } as ApiStoryDeleted,
};
const lastUpdatedAt = lastStoryId ? (newById[lastStoryId] as ApiStory | undefined)?.date : undefined;
const hasStories = Boolean(newOrderedIds.length);
global = updatePeer(global, peerId, {
hasStories,
hasUnreadStories: Boolean(hasStories && lastReadId && lastStoryId && lastReadId < lastStoryId),
});
global = updateStoriesForPeer(global, peerId, {
byId: newById,
orderedIds: newOrderedIds,
profileIds: newProfileIds,
lastUpdatedAt,
lastReadId: newLastReadId,
});
Object.values(global.byTabId).forEach((tab) => {
if (tab.storyViewer.lastViewedByPeerId && tab.storyViewer.lastViewedByPeerId[peerId] === storyId) {
global = updateLastViewedStoryForPeer(global, peerId, previousStoryId, tab.id);
}
});
if (!hasStories) {
global = {
...global,
stories: {
...global.stories,
orderedPeerIds: {
active: global.stories.orderedPeerIds.active.filter((id) => id !== peerId),
archived: global.stories.orderedPeerIds.archived.filter((id) => id !== peerId),
},
},
};
}
return global;
}
export function updateSentStoryReaction<T extends GlobalState>(
global: T,
peerId: string,
storyId: number,
reaction: ApiReaction | undefined,
): T {
const story = selectPeerStory(global, peerId, storyId);
if (!story || !('content' in story)) return global;
const { views } = story;
const reactionsCount = views?.reactionsCount || 0;
const hasReaction = views?.reactions?.some((r) => r.chosenOrder !== undefined);
const reactions = updateReactionCount(views?.reactions || [], [reaction].filter(Boolean));
const countDiff = !reaction ? -1 : hasReaction ? 0 : 1;
const newReactionsCount = reactionsCount + countDiff;
global = updatePeerStory(global, peerId, storyId, {
sentReaction: reaction,
views: {
...views,
reactionsCount: newReactionsCount,
reactions,
},
});
return global;
}
export function updatePeerStory<T extends GlobalState>(
global: T,
peerId: string,
storyId: number,
storyUpdate: Partial<ApiStory>,
): T {
const peerStories = selectPeerStories(global, peerId) || {
byId: {}, orderedIds: [], profileIds: [], archiveIds: [],
};
return {
...global,
stories: {
...global.stories,
byPeerId: {
...global.stories.byPeerId,
[peerId]: {
...peerStories,
byId: {
...peerStories.byId,
[storyId]: {
...peerStories.byId[storyId],
...storyUpdate,
},
},
},
},
},
};
}
export function updatePeerStoryViews<T extends GlobalState>(
global: T,
peerId: string,
storyId: number,
viewsUpdate: Partial<ApiStoryViews>,
): T {
const story = selectPeerStory(global, peerId, storyId);
if (!story || !('content' in story)) return global;
const { views } = story;
return updatePeerStory(global, peerId, storyId, {
views: {
...views,
...viewsUpdate,
},
});
}
export function updatePeerProfileStory<T extends GlobalState>(
global: T,
peerId: string,
storyId: number,
isInProfile?: boolean,
): T {
const peerStories = selectPeerStories(global, peerId) || {
byId: {}, orderedIds: [], profileIds: [], archiveIds: [],
};
const newProfileIds = isInProfile
? unique(peerStories.profileIds.concat(storyId)).sort((a, b) => b - a)
: peerStories.profileIds.filter((id) => storyId !== id);
return {
...global,
stories: {
...global.stories,
byPeerId: {
...global.stories.byPeerId,
[peerId]: {
...peerStories,
profileIds: newProfileIds,
},
},
},
};
}
export function updatePeerStoriesHidden<T extends GlobalState>(global: T, peerId: string, areHidden: boolean) {
const peer = selectPeer(global, peerId);
if (!peer) return global;
const currentState = peer.areStoriesHidden;
if (currentState === areHidden) return global; // `updateOrderedStoriesPeerIds` is computationally expensive
global = updatePeer(global, peerId, {
areStoriesHidden: areHidden,
});
return updateOrderedStoriesPeerIds(global, [peerId]);
}
function updateOrderedStoriesPeerIds<T extends GlobalState>(global: T, updatePeerIds: string[]): T {
const { currentUserId, stories: { byPeerId, orderedPeerIds } } = global;
const allPeerIds = orderedPeerIds.active.concat(orderedPeerIds.archived).concat(updatePeerIds);
const newOrderedPeerIds = allPeerIds.reduce<{ active: string[]; archived: string[] }>((acc, peerId) => {
if (!byPeerId[peerId]?.orderedIds?.length) return acc;
const peer = selectPeer(global, peerId);
if (peer?.areStoriesHidden) {
acc.archived.push(peerId);
} else {
acc.active.push(peerId);
}
return acc;
}, { active: [], archived: [] });
function compare(peerIdA: string, peerIdB: string) {
const peerA = selectPeer(global, peerIdA)!;
const peerB = selectPeer(global, peerIdB)!;
const diffCurrentUser = compareFields(currentUserId === peerIdA, currentUserId === peerIdB);
if (diffCurrentUser) return diffCurrentUser;
const { lastUpdatedAt: luaA = 0, orderedIds: orderedA, lastReadId: lriA = 0 } = byPeerId[peerIdA] || {};
const hasUnreadA = lriA < orderedA?.[orderedA.length - 1];
const { lastUpdatedAt: luaB = 0, orderedIds: orderedB, lastReadId: lriB = 0 } = byPeerId[peerIdB] || {};
const hasUnreadB = lriB < orderedB?.[orderedB.length - 1];
const diffUnread = compareFields(hasUnreadA, hasUnreadB);
if (diffUnread) return diffUnread;
const diffPremium = compareFields('isPremium' in peerA, 'isPremium' in peerB);
if (diffPremium) return diffPremium;
const diffType = compareFields(isUserId(peerIdA), isUserId(peerIdB));
if (diffType) return diffType;
return compareFields(luaA, luaB);
}
newOrderedPeerIds.archived = unique(newOrderedPeerIds.archived)
.filter((peerId) => byPeerId[peerId]?.orderedIds?.length)
.sort(compare);
newOrderedPeerIds.active = unique(newOrderedPeerIds.active)
.filter((peerId) => byPeerId[peerId]?.orderedIds?.length)
.sort(compare);
return {
...global,
stories: {
...global.stories,
orderedPeerIds: newOrderedPeerIds,
},
};
}
function updatePeerLastUpdatedAt<T extends GlobalState>(global: T, peerId: string): T {
const peerStories = global.stories.byPeerId[peerId];
const lastUpdatedAt = peerStories.orderedIds.reduce<number | undefined>((acc, storyId) => {
const { date } = peerStories.byId[storyId] as ApiStorySkipped || {};
if (date && (!acc || acc < date)) {
acc = date;
}
return acc;
}, undefined);
return {
...global,
stories: {
...global.stories,
byPeerId: {
...global.stories.byPeerId,
[peerId]: {
...peerStories,
lastUpdatedAt,
},
},
},
};
}
export function updateStealthMode<T extends GlobalState>(
global: T,
stealthMode: ApiStealthMode,
): T {
return {
...global,
stories: {
...global.stories,
stealthMode,
},
};
}