mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-09-12 11:37:39 +01:00
Fix pinned messages state syncing
This commit is contained in:
@@ -25,6 +25,8 @@ const PIN_1: Pin = {
|
||||
},
|
||||
message: {
|
||||
id: 'message-1',
|
||||
sentAtTimestamp: 1,
|
||||
receivedAtCounter: 1,
|
||||
poll: {
|
||||
question: 'What should we get for lunch?',
|
||||
},
|
||||
@@ -40,6 +42,8 @@ const PIN_2: Pin = {
|
||||
},
|
||||
message: {
|
||||
id: 'message-2',
|
||||
sentAtTimestamp: 2,
|
||||
receivedAtCounter: 2,
|
||||
text: {
|
||||
body: 'We found a cute pottery store close to Inokashira Park that we’re going to check out on Saturday. Anyone want to meet at the south exit at Kichijoji station at 1pm? Too early?',
|
||||
bodyRanges: [
|
||||
@@ -59,6 +63,8 @@ const PIN_3: Pin = {
|
||||
},
|
||||
message: {
|
||||
id: 'message-3',
|
||||
sentAtTimestamp: 3,
|
||||
receivedAtCounter: 3,
|
||||
text: {
|
||||
body: 'Photo',
|
||||
bodyRanges: [],
|
||||
@@ -107,7 +113,10 @@ export function Default(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function Variant(props: { title: string; message: Omit<PinMessage, 'id'> }) {
|
||||
function Variant(props: {
|
||||
title: string;
|
||||
message: Omit<PinMessage, 'id' | 'sentAtTimestamp' | 'receivedAtCounter'>;
|
||||
}) {
|
||||
const pin: Pin = {
|
||||
id: 1 as PinnedMessageId,
|
||||
sender: {
|
||||
@@ -117,6 +126,8 @@ function Variant(props: { title: string; message: Omit<PinMessage, 'id'> }) {
|
||||
},
|
||||
message: {
|
||||
id: 'message-1',
|
||||
sentAtTimestamp: 1,
|
||||
receivedAtCounter: 1,
|
||||
...props.message,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,6 +43,8 @@ export type PinMessagePoll = Readonly<{
|
||||
|
||||
export type PinMessage = Readonly<{
|
||||
id: string;
|
||||
sentAtTimestamp: number;
|
||||
receivedAtCounter: number;
|
||||
text?: PinMessageText | null;
|
||||
attachment?: PinMessageAttachment | null;
|
||||
contact?: PinMessageContact | null;
|
||||
@@ -186,7 +188,7 @@ function TabsList(props: {
|
||||
return (
|
||||
<AriaClickable.SubWidget>
|
||||
<Tabs.List className={tw('flex h-full flex-col')}>
|
||||
{props.pins.map((pin, pinIndex) => {
|
||||
{props.pins.toReversed().map((pin, pinIndex) => {
|
||||
return (
|
||||
<TabTrigger
|
||||
key={pin.id}
|
||||
|
||||
@@ -13,7 +13,7 @@ import React, {
|
||||
import { useLayoutEffect } from '@react-aria/utils';
|
||||
import type { LocalizerType } from '../../../types/I18N.std.js';
|
||||
import type { ConversationType } from '../../../state/ducks/conversations.preload.js';
|
||||
import type { PinnedMessageRenderData } from '../../../types/PinnedMessage.std.js';
|
||||
import type { PinnedMessage } from '../../../types/PinnedMessage.std.js';
|
||||
import type { SmartTimelineItemProps } from '../../../state/smart/TimelineItem.preload.js';
|
||||
import { WidthBreakpoint } from '../../_util.std.js';
|
||||
import { AxoScrollArea } from '../../../axo/AxoScrollArea.dom.js';
|
||||
@@ -32,7 +32,7 @@ import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
|
||||
export type PinnedMessagesPanelProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
conversation: ConversationType;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessage>;
|
||||
canPinMessages: boolean;
|
||||
onPinnedMessageRemoveAll: () => void;
|
||||
renderTimelineItem: (props: SmartTimelineItemProps) => React.JSX.Element;
|
||||
@@ -71,7 +71,7 @@ export const PinnedMessagesPanel = memo(function PinnedMessagesPanel(
|
||||
const next = props.pinnedMessages[pinnedMessageIndex + 1];
|
||||
const prev = props.pinnedMessages[pinnedMessageIndex - 1];
|
||||
return (
|
||||
<Fragment key={pinnedMessage.pinnedMessage.id}>
|
||||
<Fragment key={pinnedMessage.id}>
|
||||
{props.renderTimelineItem({
|
||||
containerElementRef,
|
||||
containerWidthBreakpoint,
|
||||
@@ -80,9 +80,9 @@ export const PinnedMessagesPanel = memo(function PinnedMessagesPanel(
|
||||
isBlocked: props.conversation.isBlocked ?? false,
|
||||
isGroup: props.conversation.type === 'group',
|
||||
isOldestTimelineItem: pinnedMessageIndex === 0,
|
||||
messageId: pinnedMessage.message.id,
|
||||
nextMessageId: next?.message.id,
|
||||
previousMessageId: prev?.message.id,
|
||||
messageId: pinnedMessage.messageId,
|
||||
nextMessageId: next?.messageId,
|
||||
previousMessageId: prev?.messageId,
|
||||
unreadIndicatorPlacement: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
|
||||
@@ -121,7 +121,7 @@ export async function onPinnedMessageAdd(
|
||||
});
|
||||
}
|
||||
|
||||
window.reduxActions.pinnedMessages.onPinnedMessagesChanged(
|
||||
window.reduxActions.conversations.onPinnedMessagesChanged(
|
||||
targetConversation.id
|
||||
);
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export async function onPinnedMessageRemove(
|
||||
);
|
||||
drop(pinnedMessagesCleanupService.trigger('onPinnedMessageRemove'));
|
||||
|
||||
window.reduxActions.pinnedMessages.onPinnedMessagesChanged(
|
||||
window.reduxActions.conversations.onPinnedMessagesChanged(
|
||||
targetConversationId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ const {
|
||||
getMostRecentAddressableMessages,
|
||||
getMostRecentAddressableNondisappearingMessages,
|
||||
getNewerMessagesByConversation,
|
||||
getPinnedMessagesForConversation,
|
||||
getPinnedMessagesPreloadDataForConversation,
|
||||
} = DataReader;
|
||||
const { addStickerPackReference } = DataWriter;
|
||||
|
||||
@@ -1698,13 +1698,14 @@ export class ConversationModel {
|
||||
`latest timestamp=${cleaned.at(-1)?.sent_at}`
|
||||
);
|
||||
|
||||
const pinnedMessages = await getPinnedMessagesForConversation(this.id);
|
||||
const pinnedMessagesPreloadData =
|
||||
await getPinnedMessagesPreloadDataForConversation(this.id);
|
||||
|
||||
addPreloadData({
|
||||
conversationId: this.id,
|
||||
messages: cleaned,
|
||||
metrics,
|
||||
pinnedMessages,
|
||||
pinnedMessagesPreloadData,
|
||||
unboundedFetch,
|
||||
});
|
||||
} finally {
|
||||
@@ -1816,8 +1817,10 @@ export class ConversationModel {
|
||||
`latest timestamp=${cleaned.at(-1)?.sent_at}`
|
||||
);
|
||||
|
||||
const pinnedMessages =
|
||||
await DataReader.getPinnedMessagesForConversation(conversationId);
|
||||
const pinnedMessagesPreloadData =
|
||||
await DataReader.getPinnedMessagesPreloadDataForConversation(
|
||||
conversationId
|
||||
);
|
||||
|
||||
// Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got
|
||||
// the most recent N messages in the conversation. If it has a conflict with
|
||||
@@ -1829,7 +1832,7 @@ export class ConversationModel {
|
||||
conversationId,
|
||||
messages: cleaned,
|
||||
metrics,
|
||||
pinnedMessages,
|
||||
pinnedMessagesPreloadData,
|
||||
scrollToMessageId,
|
||||
unboundedFetch,
|
||||
});
|
||||
@@ -1994,14 +1997,14 @@ export class ConversationModel {
|
||||
const scrollToMessageId =
|
||||
options && options.disableScroll ? undefined : messageId;
|
||||
|
||||
const pinnedMessages =
|
||||
await DataReader.getPinnedMessagesForConversation(conversationId);
|
||||
const pinnedMessagesPreloadData =
|
||||
await getPinnedMessagesPreloadDataForConversation(conversationId);
|
||||
|
||||
messagesReset({
|
||||
conversationId,
|
||||
messages: cleaned,
|
||||
metrics,
|
||||
pinnedMessages,
|
||||
pinnedMessagesPreloadData,
|
||||
scrollToMessageId,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -69,7 +69,7 @@ import type {
|
||||
PinnedMessage,
|
||||
PinnedMessageId,
|
||||
PinnedMessageParams,
|
||||
PinnedMessageRenderData,
|
||||
PinnedMessagePreloadData,
|
||||
} from '../types/PinnedMessage.std.js';
|
||||
import type { AppendPinnedMessageResult } from './server/pinnedMessages.std.js';
|
||||
import type {
|
||||
@@ -986,9 +986,9 @@ type ReadableInterface = {
|
||||
getAllMegaphones: () => ReadonlyArray<RemoteMegaphoneType>;
|
||||
hasMegaphone: (megaphoneId: RemoteMegaphoneId) => boolean;
|
||||
|
||||
getPinnedMessagesForConversation: (
|
||||
getPinnedMessagesPreloadDataForConversation: (
|
||||
conversationId: string
|
||||
) => ReadonlyArray<PinnedMessageRenderData>;
|
||||
) => ReadonlyArray<PinnedMessagePreloadData>;
|
||||
getNextExpiringPinnedMessageAcrossConversations: () => PinnedMessage | null;
|
||||
|
||||
getMessagesNeedingUpgrade: (
|
||||
|
||||
@@ -259,7 +259,7 @@ import {
|
||||
deleteExpiredChatFolders,
|
||||
} from './server/chatFolders.std.js';
|
||||
import {
|
||||
getPinnedMessagesForConversation,
|
||||
getPinnedMessagesPreloadDataForConversation,
|
||||
getNextExpiringPinnedMessageAcrossConversations,
|
||||
appendPinnedMessage,
|
||||
deletePinnedMessageByMessageId,
|
||||
@@ -493,7 +493,7 @@ export const DataReader: ServerReadableInterface = {
|
||||
getAllMegaphones,
|
||||
hasMegaphone,
|
||||
|
||||
getPinnedMessagesForConversation,
|
||||
getPinnedMessagesPreloadDataForConversation,
|
||||
getNextExpiringPinnedMessageAcrossConversations,
|
||||
|
||||
callLinkExists,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PinnedMessage,
|
||||
PinnedMessageId,
|
||||
PinnedMessageParams,
|
||||
PinnedMessageRenderData,
|
||||
PinnedMessagePreloadData,
|
||||
} from '../../types/PinnedMessage.std.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import { hydrateMessage } from '../hydration.std.js';
|
||||
@@ -34,10 +34,10 @@ function _getMessageById(
|
||||
return hydrateMessage(db, row);
|
||||
}
|
||||
|
||||
function _getPinnedMessageRenderData(
|
||||
function _getPinnedMessagePreloadData(
|
||||
db: ReadableDB,
|
||||
pinnedMessage: PinnedMessage
|
||||
): PinnedMessageRenderData {
|
||||
): PinnedMessagePreloadData {
|
||||
const message = _getMessageById(db, pinnedMessage.messageId);
|
||||
strictAssert(
|
||||
message != null,
|
||||
@@ -46,10 +46,10 @@ function _getPinnedMessageRenderData(
|
||||
return { pinnedMessage, message };
|
||||
}
|
||||
|
||||
export function getPinnedMessagesForConversation(
|
||||
export function getPinnedMessagesPreloadDataForConversation(
|
||||
db: ReadableDB,
|
||||
conversationId: string
|
||||
): ReadonlyArray<PinnedMessageRenderData> {
|
||||
): ReadonlyArray<PinnedMessagePreloadData> {
|
||||
return db.transaction(() => {
|
||||
const [query, params] = sql`
|
||||
SELECT * FROM pinnedMessages
|
||||
@@ -61,7 +61,7 @@ export function getPinnedMessagesForConversation(
|
||||
.prepare(query)
|
||||
.all<PinnedMessage>(params)
|
||||
.map(pinnedMessage => {
|
||||
return _getPinnedMessageRenderData(db, pinnedMessage);
|
||||
return _getPinnedMessagePreloadData(db, pinnedMessage);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { actions as megaphones } from './ducks/megaphones.preload.js';
|
||||
import { actions as nav } from './ducks/nav.std.js';
|
||||
import { actions as network } from './ducks/network.dom.js';
|
||||
import { actions as notificationProfiles } from './ducks/notificationProfiles.preload.js';
|
||||
import { actions as pinnedMessages } from './ducks/pinnedMessages.preload.js';
|
||||
import { actions as safetyNumber } from './ducks/safetyNumber.preload.js';
|
||||
import { actions as search } from './ducks/search.preload.js';
|
||||
import { actions as stickers } from './ducks/stickers.preload.js';
|
||||
@@ -68,7 +67,6 @@ export const actionCreators: ReduxActions = {
|
||||
nav,
|
||||
network,
|
||||
notificationProfiles,
|
||||
pinnedMessages,
|
||||
safetyNumber,
|
||||
search,
|
||||
stickers,
|
||||
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
ConversationAttributesType,
|
||||
DraftEditMessageType,
|
||||
LastMessageStatus,
|
||||
MessageAttributesType,
|
||||
ReadonlyMessageAttributesType,
|
||||
} from '../../model-types.d.ts';
|
||||
import type {
|
||||
@@ -96,6 +97,7 @@ import {
|
||||
getPendingAvatarDownloadSelector,
|
||||
getAllConversations,
|
||||
getActivePanel,
|
||||
getSelectedConversationId,
|
||||
} from '../selectors/conversations.dom.js';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import type {
|
||||
@@ -243,7 +245,14 @@ import { CurrentChatFolders } from '../../types/CurrentChatFolders.std.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
import { enqueuePollVoteForSend as enqueuePollVoteForSendHelper } from '../../polls/enqueuePollVoteForSend.preload.js';
|
||||
import { updateChatFolderStateOnTargetConversationChanged } from './chatFolders.preload.js';
|
||||
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
|
||||
import type {
|
||||
PinnedMessage,
|
||||
PinnedMessagePreloadData,
|
||||
} from '../../types/PinnedMessage.std.js';
|
||||
import type { StateThunk } from '../types.std.js';
|
||||
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js';
|
||||
import { getPinnedMessageExpiresAt } from '../../util/pinnedMessages.std.js';
|
||||
import { pinnedMessagesCleanupService } from '../../services/expiring/pinnedMessagesCleanupService.preload.js';
|
||||
|
||||
const {
|
||||
chunk,
|
||||
@@ -496,6 +505,7 @@ export type ConversationMessageType = ReadonlyDeep<{
|
||||
isNearBottom?: boolean;
|
||||
messageChangeCounter: number;
|
||||
messageIds: ReadonlyArray<string>;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessage>;
|
||||
messageLoadingState?: undefined | TimelineMessageLoadingState;
|
||||
metrics: MessageMetricsType;
|
||||
scrollToMessageId?: string;
|
||||
@@ -504,7 +514,7 @@ export type ConversationMessageType = ReadonlyDeep<{
|
||||
export type ConversationPreloadDataType = ReadonlyDeep<{
|
||||
conversationId: string;
|
||||
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
|
||||
pinnedMessagesPreloadData: ReadonlyArray<PinnedMessagePreloadData>;
|
||||
metrics: MessageMetricsType;
|
||||
unboundedFetch: boolean;
|
||||
}>;
|
||||
@@ -714,6 +724,7 @@ export const SET_PROFILE_UPDATE_ERROR =
|
||||
export const ADD_PRELOAD_DATA = 'conversations/ADD_PRELOAD_DATA';
|
||||
export const CONSUME_PRELOAD_DATA = 'conversations/CONSUME_PRELOAD_DATA';
|
||||
export const MESSAGES_RESET = 'conversations/MESSAGES_RESET';
|
||||
const PINNED_MESSAGES_REPLACE = 'conversations/PINNED_MESSAGES_REPLACE';
|
||||
|
||||
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
|
||||
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
|
||||
@@ -1087,6 +1098,14 @@ type PanelAnimationStartedActionType = ReadonlyDeep<{
|
||||
payload: null;
|
||||
}>;
|
||||
|
||||
type PinnedMessagesReplace = ReadonlyDeep<{
|
||||
type: typeof PINNED_MESSAGES_REPLACE;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
pinnedMessagesPreloadData: ReadonlyArray<PinnedMessagePreloadData>;
|
||||
};
|
||||
}>;
|
||||
|
||||
type ReplaceAvatarsActionType = ReadonlyDeep<{
|
||||
type: typeof REPLACE_AVATARS;
|
||||
payload: {
|
||||
@@ -1145,6 +1164,7 @@ export type ConversationActionType =
|
||||
| PanelAnimationDoneActionType
|
||||
| PopPanelActionType
|
||||
| PushPanelActionType
|
||||
| PinnedMessagesReplace
|
||||
| RemoveAllConversationsActionType
|
||||
| RepairNewestMessageActionType
|
||||
| RepairOldestMessageActionType
|
||||
@@ -1245,6 +1265,9 @@ export const actions = {
|
||||
onMarkUnread,
|
||||
onMoveToInbox,
|
||||
onUndoArchive,
|
||||
onPinnedMessagesChanged,
|
||||
onPinnedMessageAdd,
|
||||
onPinnedMessageRemove,
|
||||
openGiftBadge,
|
||||
popPanelForConversation,
|
||||
pushPanelForConversation,
|
||||
@@ -3381,7 +3404,7 @@ function messagesReset({
|
||||
conversationId,
|
||||
messages,
|
||||
metrics,
|
||||
pinnedMessages,
|
||||
pinnedMessagesPreloadData,
|
||||
scrollToMessageId,
|
||||
unboundedFetch,
|
||||
}: MessagesResetOptionsType): MessagesResetActionType {
|
||||
@@ -3400,7 +3423,7 @@ function messagesReset({
|
||||
conversationId,
|
||||
messages,
|
||||
metrics,
|
||||
pinnedMessages,
|
||||
pinnedMessagesPreloadData,
|
||||
scrollToMessageId,
|
||||
},
|
||||
};
|
||||
@@ -5103,6 +5126,120 @@ function startAvatarDownload(
|
||||
};
|
||||
}
|
||||
|
||||
function getMessageAuthorAci(
|
||||
message: ReadonlyMessageAttributesType
|
||||
): AciString {
|
||||
if (isIncoming(message)) {
|
||||
strictAssert(
|
||||
isAciString(message.sourceServiceId),
|
||||
'Message sourceServiceId must be an ACI'
|
||||
);
|
||||
return message.sourceServiceId;
|
||||
}
|
||||
return itemStorage.user.getCheckedAci();
|
||||
}
|
||||
|
||||
type PinnedMessageTarget = ReadonlyDeep<{
|
||||
conversationId: string;
|
||||
targetMessageId: string;
|
||||
targetAuthorAci: AciString;
|
||||
targetSentTimestamp: number;
|
||||
}>;
|
||||
|
||||
async function getPinnedMessageTarget(
|
||||
targetMessageId: string
|
||||
): Promise<PinnedMessageTarget> {
|
||||
const message = await DataReader.getMessageById(targetMessageId);
|
||||
if (message == null) {
|
||||
throw new Error('getPinnedMessageTarget: Target message not found');
|
||||
}
|
||||
return {
|
||||
conversationId: message.conversationId,
|
||||
targetMessageId: message.id,
|
||||
targetAuthorAci: getMessageAuthorAci(message),
|
||||
targetSentTimestamp: message.sent_at,
|
||||
};
|
||||
}
|
||||
|
||||
function onPinnedMessagesChanged(
|
||||
conversationId: string
|
||||
): StateThunk<PinnedMessagesReplace> {
|
||||
return async (dispatch, getState) => {
|
||||
const selectedConversationId = getSelectedConversationId(getState());
|
||||
if (
|
||||
selectedConversationId == null ||
|
||||
selectedConversationId !== conversationId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pinnedMessagesPreloadData =
|
||||
await DataReader.getPinnedMessagesPreloadDataForConversation(
|
||||
conversationId
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: PINNED_MESSAGES_REPLACE,
|
||||
payload: {
|
||||
conversationId,
|
||||
pinnedMessagesPreloadData,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function onPinnedMessageAdd(
|
||||
targetMessageId: string,
|
||||
pinDurationSeconds: DurationInSeconds | null
|
||||
): StateThunk {
|
||||
return async dispatch => {
|
||||
const target = await getPinnedMessageTarget(targetMessageId);
|
||||
const targetConversation = window.ConversationController.get(
|
||||
target.conversationId
|
||||
);
|
||||
strictAssert(targetConversation != null, 'Missing target conversation');
|
||||
|
||||
await conversationJobQueue.add({
|
||||
type: conversationQueueJobEnum.enum.PinMessage,
|
||||
...target,
|
||||
pinDurationSeconds,
|
||||
});
|
||||
|
||||
const pinnedMessagesLimit = getPinnedMessagesLimit();
|
||||
|
||||
const pinnedAt = Date.now();
|
||||
const expiresAt = getPinnedMessageExpiresAt(pinnedAt, pinDurationSeconds);
|
||||
|
||||
await DataWriter.appendPinnedMessage(pinnedMessagesLimit, {
|
||||
conversationId: target.conversationId,
|
||||
messageId: target.targetMessageId,
|
||||
expiresAt,
|
||||
pinnedAt,
|
||||
});
|
||||
drop(pinnedMessagesCleanupService.trigger('onPinnedMessageAdd'));
|
||||
|
||||
await targetConversation.addNotification('pinned-message-notification', {
|
||||
pinnedMessageId: targetMessageId,
|
||||
sourceServiceId: itemStorage.user.getCheckedAci(),
|
||||
});
|
||||
|
||||
dispatch(onPinnedMessagesChanged(target.conversationId));
|
||||
};
|
||||
}
|
||||
|
||||
function onPinnedMessageRemove(targetMessageId: string): StateThunk {
|
||||
return async dispatch => {
|
||||
const target = await getPinnedMessageTarget(targetMessageId);
|
||||
await conversationJobQueue.add({
|
||||
type: conversationQueueJobEnum.enum.UnpinMessage,
|
||||
...target,
|
||||
});
|
||||
await DataWriter.deletePinnedMessageByMessageId(targetMessageId);
|
||||
drop(pinnedMessagesCleanupService.trigger('onPinnedMessageRemove'));
|
||||
dispatch(onPinnedMessagesChanged(target.conversationId));
|
||||
};
|
||||
}
|
||||
|
||||
// Reducer
|
||||
|
||||
export function getEmptyState(): ConversationsStateType {
|
||||
@@ -5524,6 +5661,7 @@ function updateMessageLookup(
|
||||
metrics,
|
||||
scrollToMessageId,
|
||||
unboundedFetch,
|
||||
pinnedMessagesPreloadData,
|
||||
}: MessagesResetDataType
|
||||
): ConversationsStateType {
|
||||
const { messagesByConversation, messagesLookup } = state;
|
||||
@@ -5556,6 +5694,14 @@ function updateMessageLookup(
|
||||
|
||||
const messageIds = sorted.map(message => message.id);
|
||||
|
||||
const extraMessagesLookup: Record<string, MessageType> = {};
|
||||
const pinnedMessages: Array<PinnedMessage> = [];
|
||||
|
||||
for (const { pinnedMessage, message } of pinnedMessagesPreloadData) {
|
||||
extraMessagesLookup[message.id] = message;
|
||||
pinnedMessages.push(pinnedMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
preloadData: undefined,
|
||||
@@ -5568,6 +5714,7 @@ function updateMessageLookup(
|
||||
: {}),
|
||||
messagesLookup: {
|
||||
...messagesLookup,
|
||||
...extraMessagesLookup,
|
||||
...lookup,
|
||||
},
|
||||
messagesByConversation: {
|
||||
@@ -5579,6 +5726,7 @@ function updateMessageLookup(
|
||||
? existingConversation.scrollToMessageCounter + 1
|
||||
: 0,
|
||||
messageIds,
|
||||
pinnedMessages,
|
||||
metrics: {
|
||||
...metrics,
|
||||
newest,
|
||||
@@ -5589,6 +5737,63 @@ function updateMessageLookup(
|
||||
};
|
||||
}
|
||||
|
||||
function maybeDropMessageIdsFromMessagesLookup(
|
||||
messagesLookup: MessageLookupType,
|
||||
messageIdsToMaybeRemove: ReadonlyArray<string>,
|
||||
pinnedMessages: ReadonlyArray<PinnedMessage>
|
||||
): MessageLookupType {
|
||||
const pinnedMessagesMessageIds = new Set<string>();
|
||||
for (const pinnedMessage of pinnedMessages) {
|
||||
pinnedMessagesMessageIds.add(pinnedMessage.messageId);
|
||||
}
|
||||
|
||||
const messageIdsToRemove = new Set<string>();
|
||||
for (const messageIdToMaybeRemove of messageIdsToMaybeRemove) {
|
||||
if (!pinnedMessagesMessageIds.has(messageIdToMaybeRemove)) {
|
||||
messageIdsToRemove.add(messageIdToMaybeRemove);
|
||||
}
|
||||
}
|
||||
|
||||
if (messageIdsToRemove.size === 0) {
|
||||
return messagesLookup;
|
||||
}
|
||||
|
||||
const updatedMessagesLookup: Record<string, MessageWithUIFieldsType> = {};
|
||||
for (const messageId of Object.keys(messagesLookup)) {
|
||||
if (!messageIdsToRemove.has(messageId)) {
|
||||
updatedMessagesLookup[messageId] = messagesLookup[messageId];
|
||||
}
|
||||
}
|
||||
|
||||
return updatedMessagesLookup;
|
||||
}
|
||||
|
||||
function maybeDropMessageIdFromPinnedMessages(
|
||||
messagesByConversation: MessagesByConversationType,
|
||||
conversationId: string,
|
||||
messageId: string
|
||||
): MessagesByConversationType {
|
||||
const prevConversationMessages = messagesByConversation[conversationId];
|
||||
if (
|
||||
prevConversationMessages == null ||
|
||||
prevConversationMessages.pinnedMessages.length === 0
|
||||
) {
|
||||
return messagesByConversation;
|
||||
}
|
||||
|
||||
return {
|
||||
...messagesByConversation,
|
||||
[conversationId]: {
|
||||
...prevConversationMessages,
|
||||
pinnedMessages: prevConversationMessages.pinnedMessages.filter(
|
||||
pinnedMessage => {
|
||||
return pinnedMessage.messageId !== messageId;
|
||||
}
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dropPreloadData(
|
||||
state: ConversationsStateType
|
||||
): ConversationsStateType {
|
||||
@@ -5736,7 +5941,11 @@ export function reducer(
|
||||
|
||||
return {
|
||||
...state,
|
||||
messagesLookup: omit(state.messagesLookup, messageIdsToRemove),
|
||||
messagesLookup: maybeDropMessageIdsFromMessagesLookup(
|
||||
state.messagesLookup,
|
||||
messageIdsToRemove,
|
||||
conversationMessages?.pinnedMessages ?? []
|
||||
),
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[conversationId]: {
|
||||
@@ -5767,7 +5976,11 @@ export function reducer(
|
||||
|
||||
return {
|
||||
...state,
|
||||
messagesLookup: omit(state.messagesLookup, messageIdsToRemove),
|
||||
messagesLookup: maybeDropMessageIdsFromMessagesLookup(
|
||||
state.messagesLookup,
|
||||
messageIdsToRemove,
|
||||
conversationMessages.pinnedMessages
|
||||
),
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[conversationId]: {
|
||||
@@ -5877,7 +6090,11 @@ export function reducer(
|
||||
stack: [],
|
||||
watermark: -1,
|
||||
},
|
||||
messagesLookup: omit(state.messagesLookup, [...messageIds]),
|
||||
messagesLookup: maybeDropMessageIdsFromMessagesLookup(
|
||||
state.messagesLookup,
|
||||
[...messageIds],
|
||||
[]
|
||||
),
|
||||
messagesByConversation: omit(state.messagesByConversation, [
|
||||
conversationId,
|
||||
]),
|
||||
@@ -6232,6 +6449,8 @@ export function reducer(
|
||||
: existingMessage.isSpoilerExpanded,
|
||||
};
|
||||
|
||||
const wasDeletedForEveryone = updatedMessage.deletedForEveryone;
|
||||
|
||||
return {
|
||||
...maybeUpdateSelectedMessageForDetails(
|
||||
{
|
||||
@@ -6245,6 +6464,13 @@ export function reducer(
|
||||
...state.messagesLookup,
|
||||
[id]: updatedMessage,
|
||||
},
|
||||
messagesByConversation: !wasDeletedForEveryone
|
||||
? state.messagesByConversation
|
||||
: maybeDropMessageIdFromPinnedMessages(
|
||||
state.messagesByConversation,
|
||||
conversationId,
|
||||
id
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6497,18 +6723,29 @@ export function reducer(
|
||||
};
|
||||
}
|
||||
|
||||
const pinnedMessages = existingConversation.pinnedMessages.filter(
|
||||
pinnedMesage => {
|
||||
return pinnedMesage.messageId !== id;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
...maybeUpdateSelectedMessageForDetails(
|
||||
{ messageId: id, targetedMessageForDetails: undefined },
|
||||
state
|
||||
),
|
||||
preloadData: undefined,
|
||||
messagesLookup: omit(messagesLookup, id),
|
||||
messagesLookup: maybeDropMessageIdsFromMessagesLookup(
|
||||
messagesLookup,
|
||||
[id],
|
||||
pinnedMessages
|
||||
),
|
||||
messagesByConversation: {
|
||||
[conversationId]: {
|
||||
...existingConversation,
|
||||
messageIds,
|
||||
metrics,
|
||||
pinnedMessages,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -7512,5 +7749,39 @@ export function reducer(
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === PINNED_MESSAGES_REPLACE) {
|
||||
const { conversationId, pinnedMessagesPreloadData } = action.payload;
|
||||
|
||||
const extraMessagesLookup: Record<string, MessageAttributesType> = {};
|
||||
const pinnedMessages: Array<PinnedMessage> = [];
|
||||
|
||||
for (const pinnedMessagePreloadData of pinnedMessagesPreloadData) {
|
||||
const { message, pinnedMessage } = pinnedMessagePreloadData;
|
||||
extraMessagesLookup[message.id] = message;
|
||||
pinnedMessages.push(pinnedMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
messagesLookup:
|
||||
state.selectedConversationId !== conversationId
|
||||
? state.messagesLookup
|
||||
: {
|
||||
...state.messagesLookup,
|
||||
...extraMessagesLookup,
|
||||
},
|
||||
messagesByConversation:
|
||||
state.messagesByConversation[conversationId] == null
|
||||
? state.messagesByConversation
|
||||
: {
|
||||
...state.messagesByConversation,
|
||||
[conversationId]: {
|
||||
...state.messagesByConversation[conversationId],
|
||||
pinnedMessages,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.js';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions.std.js';
|
||||
import { DataReader, DataWriter } from '../../sql/Client.preload.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
|
||||
import type { ReadonlyMessageAttributesType } from '../../model-types.js';
|
||||
import type { AciString } from '../../types/ServiceId.std.js';
|
||||
import { isIncoming } from '../selectors/message.preload.js';
|
||||
import { isAciString } from '../../util/isAciString.std.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
import type { StateThunk } from '../types.std.js';
|
||||
import type { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js';
|
||||
import {
|
||||
conversationJobQueue,
|
||||
conversationQueueJobEnum,
|
||||
} from '../../jobs/conversationJobQueue.preload.js';
|
||||
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js';
|
||||
import { getPinnedMessageExpiresAt } from '../../util/pinnedMessages.std.js';
|
||||
import { getSelectedConversationId } from '../selectors/conversations.dom.js';
|
||||
import type {
|
||||
AddPreloadDataActionType,
|
||||
ConsumePreloadDataActionType,
|
||||
ConversationUnloadedActionType,
|
||||
MessageChangedActionType,
|
||||
MessagesResetActionType,
|
||||
TargetedConversationChangedActionType,
|
||||
} from './conversations.preload.js';
|
||||
import {
|
||||
ADD_PRELOAD_DATA,
|
||||
CONSUME_PRELOAD_DATA,
|
||||
CONVERSATION_UNLOADED,
|
||||
MESSAGE_CHANGED,
|
||||
TARGETED_CONVERSATION_CHANGED,
|
||||
} from './conversations.preload.js';
|
||||
import { pinnedMessagesCleanupService } from '../../services/expiring/pinnedMessagesCleanupService.preload.js';
|
||||
import { drop } from '../../util/drop.std.js';
|
||||
|
||||
type PreloadData = ReadonlyDeep<{
|
||||
conversationId: string;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
|
||||
}>;
|
||||
|
||||
export type PinnedMessagesState = ReadonlyDeep<{
|
||||
preloadData: PreloadData | null;
|
||||
conversationId: string | null;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData> | null;
|
||||
}>;
|
||||
|
||||
const PINNED_MESSAGES_REPLACE = 'pinnedMessages/PINNED_MESSAGES_REPLACE';
|
||||
|
||||
export type PinnedMessagesReplace = ReadonlyDeep<{
|
||||
type: typeof PINNED_MESSAGES_REPLACE;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type PinnedMessagesAction = ReadonlyDeep<PinnedMessagesReplace>;
|
||||
|
||||
export function getEmptyState(): PinnedMessagesState {
|
||||
return {
|
||||
preloadData: null,
|
||||
conversationId: null,
|
||||
pinnedMessages: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getMessageAuthorAci(
|
||||
message: ReadonlyMessageAttributesType
|
||||
): AciString {
|
||||
if (isIncoming(message)) {
|
||||
strictAssert(
|
||||
isAciString(message.sourceServiceId),
|
||||
'Message sourceServiceId must be an ACI'
|
||||
);
|
||||
return message.sourceServiceId;
|
||||
}
|
||||
return itemStorage.user.getCheckedAci();
|
||||
}
|
||||
|
||||
type PinnedMessageTarget = ReadonlyDeep<{
|
||||
conversationId: string;
|
||||
targetMessageId: string;
|
||||
targetAuthorAci: AciString;
|
||||
targetSentTimestamp: number;
|
||||
}>;
|
||||
|
||||
async function getPinnedMessageTarget(
|
||||
targetMessageId: string
|
||||
): Promise<PinnedMessageTarget> {
|
||||
const message = await DataReader.getMessageById(targetMessageId);
|
||||
if (message == null) {
|
||||
throw new Error('getPinnedMessageTarget: Target message not found');
|
||||
}
|
||||
return {
|
||||
conversationId: message.conversationId,
|
||||
targetMessageId: message.id,
|
||||
targetAuthorAci: getMessageAuthorAci(message),
|
||||
targetSentTimestamp: message.sent_at,
|
||||
};
|
||||
}
|
||||
|
||||
function onPinnedMessagesChanged(
|
||||
conversationId: string
|
||||
): StateThunk<PinnedMessagesReplace> {
|
||||
return async (dispatch, getState) => {
|
||||
const selectedConversationId = getSelectedConversationId(getState());
|
||||
if (
|
||||
selectedConversationId == null ||
|
||||
selectedConversationId !== conversationId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pinnedMessages =
|
||||
await DataReader.getPinnedMessagesForConversation(conversationId);
|
||||
|
||||
dispatch({
|
||||
type: PINNED_MESSAGES_REPLACE,
|
||||
payload: {
|
||||
conversationId,
|
||||
pinnedMessages,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function onPinnedMessageAdd(
|
||||
targetMessageId: string,
|
||||
pinDurationSeconds: DurationInSeconds | null
|
||||
): StateThunk {
|
||||
return async dispatch => {
|
||||
const target = await getPinnedMessageTarget(targetMessageId);
|
||||
const targetConversation = window.ConversationController.get(
|
||||
target.conversationId
|
||||
);
|
||||
strictAssert(targetConversation != null, 'Missing target conversation');
|
||||
|
||||
await conversationJobQueue.add({
|
||||
type: conversationQueueJobEnum.enum.PinMessage,
|
||||
...target,
|
||||
pinDurationSeconds,
|
||||
});
|
||||
|
||||
const pinnedMessagesLimit = getPinnedMessagesLimit();
|
||||
|
||||
const pinnedAt = Date.now();
|
||||
const expiresAt = getPinnedMessageExpiresAt(pinnedAt, pinDurationSeconds);
|
||||
|
||||
await DataWriter.appendPinnedMessage(pinnedMessagesLimit, {
|
||||
conversationId: target.conversationId,
|
||||
messageId: target.targetMessageId,
|
||||
expiresAt,
|
||||
pinnedAt,
|
||||
});
|
||||
drop(pinnedMessagesCleanupService.trigger('onPinnedMessageAdd'));
|
||||
|
||||
await targetConversation.addNotification('pinned-message-notification', {
|
||||
pinnedMessageId: targetMessageId,
|
||||
sourceServiceId: itemStorage.user.getCheckedAci(),
|
||||
});
|
||||
|
||||
dispatch(onPinnedMessagesChanged(target.conversationId));
|
||||
};
|
||||
}
|
||||
|
||||
function onPinnedMessageRemove(targetMessageId: string): StateThunk {
|
||||
return async dispatch => {
|
||||
const target = await getPinnedMessageTarget(targetMessageId);
|
||||
await conversationJobQueue.add({
|
||||
type: conversationQueueJobEnum.enum.UnpinMessage,
|
||||
...target,
|
||||
});
|
||||
await DataWriter.deletePinnedMessageByMessageId(targetMessageId);
|
||||
drop(pinnedMessagesCleanupService.trigger('onPinnedMessageRemove'));
|
||||
dispatch(onPinnedMessagesChanged(target.conversationId));
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
onPinnedMessagesChanged,
|
||||
onPinnedMessageAdd,
|
||||
onPinnedMessageRemove,
|
||||
};
|
||||
|
||||
export const usePinnedMessagesActions = (): BoundActionCreatorsMapObject<
|
||||
typeof actions
|
||||
> => useBoundActions(actions);
|
||||
|
||||
function updateMessageInPinnedMessages(
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>,
|
||||
message: ReadonlyMessageAttributesType
|
||||
): ReadonlyArray<PinnedMessageRenderData> {
|
||||
return pinnedMessages.map(pinnedMessage => {
|
||||
if (pinnedMessage.message.id === message.id) {
|
||||
return { ...pinnedMessage, message };
|
||||
}
|
||||
return pinnedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: PinnedMessagesState = getEmptyState(),
|
||||
action:
|
||||
| PinnedMessagesAction
|
||||
| MessageChangedActionType
|
||||
| TargetedConversationChangedActionType
|
||||
| ConversationUnloadedActionType
|
||||
| AddPreloadDataActionType
|
||||
| ConsumePreloadDataActionType
|
||||
| MessagesResetActionType
|
||||
): PinnedMessagesState {
|
||||
switch (action.type) {
|
||||
case PINNED_MESSAGES_REPLACE:
|
||||
if (state.conversationId !== action.payload.conversationId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
pinnedMessages: action.payload.pinnedMessages,
|
||||
};
|
||||
case TARGETED_CONVERSATION_CHANGED: {
|
||||
const conversationId = action.payload.conversationId ?? null;
|
||||
return {
|
||||
...state,
|
||||
preloadData:
|
||||
conversationId != null &&
|
||||
state.preloadData != null &&
|
||||
state.preloadData.conversationId === conversationId
|
||||
? state.preloadData
|
||||
: null,
|
||||
conversationId,
|
||||
pinnedMessages: null,
|
||||
};
|
||||
}
|
||||
case CONVERSATION_UNLOADED:
|
||||
if (state.conversationId !== action.payload.conversationId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
conversationId: null,
|
||||
pinnedMessages: null,
|
||||
};
|
||||
case ADD_PRELOAD_DATA:
|
||||
return {
|
||||
...state,
|
||||
preloadData: {
|
||||
conversationId: action.payload.conversationId,
|
||||
pinnedMessages: action.payload.pinnedMessages,
|
||||
},
|
||||
};
|
||||
case CONSUME_PRELOAD_DATA:
|
||||
if (state.preloadData == null) {
|
||||
return state;
|
||||
}
|
||||
if (state.preloadData.conversationId === action.payload.conversationId) {
|
||||
return {
|
||||
...state,
|
||||
preloadData: null,
|
||||
conversationId: state.preloadData.conversationId,
|
||||
pinnedMessages: state.preloadData.pinnedMessages,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
preloadData: null,
|
||||
};
|
||||
case MESSAGE_CHANGED: {
|
||||
let nextState = state;
|
||||
|
||||
if (
|
||||
nextState.conversationId === action.payload.conversationId &&
|
||||
nextState.pinnedMessages != null
|
||||
) {
|
||||
nextState = {
|
||||
...nextState,
|
||||
pinnedMessages: updateMessageInPinnedMessages(
|
||||
nextState.pinnedMessages,
|
||||
action.payload.data
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
nextState.preloadData != null &&
|
||||
nextState.preloadData.conversationId === action.payload.id
|
||||
) {
|
||||
nextState = {
|
||||
...nextState,
|
||||
preloadData: {
|
||||
...nextState.preloadData,
|
||||
pinnedMessages: updateMessageInPinnedMessages(
|
||||
nextState.preloadData.pinnedMessages,
|
||||
action.payload.data
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { getEmptyState as megaphonesEmptyState } from './ducks/megaphones.preloa
|
||||
import { getEmptyState as navEmptyState } from './ducks/nav.std.js';
|
||||
import { getEmptyState as networkEmptyState } from './ducks/network.dom.js';
|
||||
import { getEmptyState as notificationProfilesEmptyState } from './ducks/notificationProfiles.preload.js';
|
||||
import { getEmptyState as pinnedMessagesEmptyState } from './ducks/pinnedMessages.preload.js';
|
||||
import { getEmptyState as preferredReactionsEmptyState } from './ducks/preferredReactions.preload.js';
|
||||
import { getEmptyState as safetyNumberEmptyState } from './ducks/safetyNumber.preload.js';
|
||||
import { getEmptyState as searchEmptyState } from './ducks/search.preload.js';
|
||||
@@ -176,7 +175,6 @@ function getEmptyState(): StateType {
|
||||
nav: navEmptyState(),
|
||||
network: networkEmptyState(),
|
||||
notificationProfiles: notificationProfilesEmptyState(),
|
||||
pinnedMessages: pinnedMessagesEmptyState(),
|
||||
preferredReactions: preferredReactionsEmptyState(),
|
||||
safetyNumber: safetyNumberEmptyState(),
|
||||
search: searchEmptyState(),
|
||||
|
||||
@@ -94,10 +94,6 @@ export function initializeRedux(data: ReduxInitData): void {
|
||||
megaphones: bindActionCreators(actionCreators.megaphones, store.dispatch),
|
||||
nav: bindActionCreators(actionCreators.nav, store.dispatch),
|
||||
network: bindActionCreators(actionCreators.network, store.dispatch),
|
||||
pinnedMessages: bindActionCreators(
|
||||
actionCreators.pinnedMessages,
|
||||
store.dispatch
|
||||
),
|
||||
notificationProfiles: bindActionCreators(
|
||||
actionCreators.notificationProfiles,
|
||||
store.dispatch
|
||||
|
||||
@@ -30,7 +30,6 @@ import { reducer as megaphones } from './ducks/megaphones.preload.js';
|
||||
import { reducer as nav } from './ducks/nav.std.js';
|
||||
import { reducer as network } from './ducks/network.dom.js';
|
||||
import { reducer as notificationProfiles } from './ducks/notificationProfiles.preload.js';
|
||||
import { reducer as pinnedMessages } from './ducks/pinnedMessages.preload.js';
|
||||
import { reducer as preferredReactions } from './ducks/preferredReactions.preload.js';
|
||||
import { reducer as safetyNumber } from './ducks/safetyNumber.preload.js';
|
||||
import { reducer as search } from './ducks/search.preload.js';
|
||||
@@ -70,7 +69,6 @@ export const reducer = combineReducers({
|
||||
nav,
|
||||
network,
|
||||
notificationProfiles,
|
||||
pinnedMessages,
|
||||
preferredReactions,
|
||||
safetyNumber,
|
||||
search,
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
import type { AllChatFoldersMutedStats } from '../../util/countMutedStats.std.js';
|
||||
import { countAllChatFoldersMutedStats } from '../../util/countMutedStats.std.js';
|
||||
import { getActiveProfile } from './notificationProfiles.dom.js';
|
||||
import type { PinnedMessage } from '../../types/PinnedMessage.std.js';
|
||||
|
||||
const { isNumber, pick } = lodash;
|
||||
|
||||
@@ -318,6 +319,18 @@ export const getConversationMessages = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
export const getPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
|
||||
createSelector(getConversationMessages, conversationMessages => {
|
||||
return conversationMessages?.pinnedMessages ?? [];
|
||||
});
|
||||
|
||||
export const getPinnedMessagesMessageIds: StateSelector<ReadonlyArray<string>> =
|
||||
createSelector(getPinnedMessages, pinnedMessages => {
|
||||
return pinnedMessages.map(pinnedMessage => {
|
||||
return pinnedMessage.messageId;
|
||||
});
|
||||
});
|
||||
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
// Note: we will probably want to put i18n and regionCode back when we are formatting
|
||||
|
||||
@@ -103,6 +103,7 @@ import {
|
||||
getMessages,
|
||||
getCachedConversationMemberColorsSelector,
|
||||
getContactNameColor,
|
||||
getPinnedMessagesMessageIds,
|
||||
} from './conversations.dom.js';
|
||||
import {
|
||||
getIntl,
|
||||
@@ -168,7 +169,6 @@ import { LONG_MESSAGE } from '../../types/MIME.std.js';
|
||||
import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification.dom.js';
|
||||
import type { PinnedMessageNotificationData } from '../../components/conversation/pinned-messages/PinnedMessageNotification.dom.js';
|
||||
import { canEditGroupInfo } from '../../util/canEditGroupInfo.preload.js';
|
||||
import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js';
|
||||
import { isPinnedMessagesSendEnabled } from '../../util/isPinnedMessagesEnabled.dom.js';
|
||||
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js';
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
|
||||
import type { PinnedMessagesState } from '../ducks/pinnedMessages.preload.js';
|
||||
import type { StateType } from '../reducer.preload.js';
|
||||
import type { StateSelector } from '../types.std.js';
|
||||
import { getSelectedConversationId } from './conversations.dom.js';
|
||||
import { softAssert } from '../../util/assert.std.js';
|
||||
|
||||
export function getPinnedMessagesState(state: StateType): PinnedMessagesState {
|
||||
return state.pinnedMessages;
|
||||
}
|
||||
|
||||
export const getPinnedMessages: StateSelector<
|
||||
ReadonlyArray<PinnedMessageRenderData>
|
||||
> = createSelector(
|
||||
getPinnedMessagesState,
|
||||
getSelectedConversationId,
|
||||
(state, selectedConversationId) => {
|
||||
const expectedConversationId = selectedConversationId ?? null;
|
||||
if (expectedConversationId !== state.conversationId) {
|
||||
softAssert(
|
||||
false,
|
||||
'getPinnedMessages: State is not in sync with the selected conversation'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return state.pinnedMessages ?? [];
|
||||
}
|
||||
);
|
||||
|
||||
export const getPinnedMessagesMessageIds: StateSelector<ReadonlyArray<string>> =
|
||||
createSelector(getPinnedMessages, pinnedMessages => {
|
||||
return pinnedMessages.map(pinnedMessage => {
|
||||
return pinnedMessage.message.id;
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSelectedMessageIds,
|
||||
getMessages,
|
||||
getCachedConversationMemberColorsSelector,
|
||||
getPinnedMessagesMessageIds,
|
||||
} from './conversations.dom.js';
|
||||
import { getAccountSelector } from './accounts.std.js';
|
||||
import {
|
||||
@@ -25,7 +26,6 @@ import { getActiveCall, getCallSelector } from './calling.std.js';
|
||||
import { getPropsForBubble } from './message.preload.js';
|
||||
import { getCallHistorySelector } from './callHistory.std.js';
|
||||
import { useProxySelector } from '../../hooks/useProxySelector.std.js';
|
||||
import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js';
|
||||
|
||||
const getTimelineItem = (
|
||||
state: StateType,
|
||||
|
||||
@@ -15,6 +15,8 @@ import { getIntl } from '../selectors/user.std.js';
|
||||
import {
|
||||
getConversationSelector,
|
||||
getSelectedConversationId,
|
||||
getPinnedMessages,
|
||||
getMessages,
|
||||
} from '../selectors/conversations.dom.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import { useConversationsActions } from '../ducks/conversations.preload.js';
|
||||
@@ -39,8 +41,6 @@ import * as Attachment from '../../util/Attachment.std.js';
|
||||
import * as MIME from '../../types/MIME.std.js';
|
||||
import * as EmbeddedContact from '../../types/EmbeddedContact.std.js';
|
||||
import type { StateSelector } from '../types.std.js';
|
||||
import { usePinnedMessagesActions } from '../ducks/pinnedMessages.preload.js';
|
||||
import { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
|
||||
|
||||
function getPinMessageAttachment(
|
||||
props: MessagePropsType
|
||||
@@ -101,9 +101,15 @@ function isPinMessageSticker(props: MessagePropsType): boolean {
|
||||
return props.isSticker ?? false;
|
||||
}
|
||||
|
||||
function getPinMessage(props: MessagePropsType): PinMessage {
|
||||
function getPinMessage(
|
||||
props: MessagePropsType,
|
||||
sentAtTimestamp: number,
|
||||
receivedAtCounter: number
|
||||
): PinMessage {
|
||||
return {
|
||||
id: props.id,
|
||||
sentAtTimestamp,
|
||||
receivedAtCounter,
|
||||
text: getPinMessageText(props),
|
||||
attachment: getPinMessageAttachment(props),
|
||||
contact: getPinMessageContact(props),
|
||||
@@ -153,23 +159,22 @@ function getNextPinId(
|
||||
|
||||
const selectPins: StateSelector<ReadonlyArray<Pin>> = createSelector(
|
||||
getPinnedMessages,
|
||||
getMessages,
|
||||
getMessagePropsSelector,
|
||||
(pinnedMessages, messagePropsSelector) => {
|
||||
const sorted = orderBy(
|
||||
pinnedMessages,
|
||||
['message.received_at', 'message.sent_at'],
|
||||
['ASC', 'ASC']
|
||||
);
|
||||
|
||||
return sorted.map((pinnedMessageRenderData): Pin => {
|
||||
const { pinnedMessage, message } = pinnedMessageRenderData;
|
||||
|
||||
(pinnedMessages, messagesLookup, messagePropsSelector) => {
|
||||
return pinnedMessages.map((pinnedMessage): Pin => {
|
||||
const message = messagesLookup[pinnedMessage.messageId];
|
||||
strictAssert(message != null, 'Missing pinned message');
|
||||
const messageProps = messagePropsSelector(message);
|
||||
|
||||
return {
|
||||
id: pinnedMessage.id,
|
||||
sender: getPinSender(messageProps),
|
||||
message: getPinMessage(messageProps),
|
||||
message: getPinMessage(
|
||||
messageProps,
|
||||
message.sent_at,
|
||||
message.received_at
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -187,7 +192,7 @@ function getNodeDataMessageId(node: Node): string | null {
|
||||
}
|
||||
|
||||
function useTimelineIntersectionObserver(
|
||||
pins: ReadonlyArray<Pin>,
|
||||
unsortedPins: ReadonlyArray<Pin>,
|
||||
onCurrentChange: (current: PinnedMessageId) => void
|
||||
) {
|
||||
const onCurrentChangeRef = useRef(onCurrentChange);
|
||||
@@ -197,10 +202,19 @@ function useTimelineIntersectionObserver(
|
||||
|
||||
useEffect(() => {
|
||||
// We only need to track anything if there are multiple pins
|
||||
if (pins.length <= 1) {
|
||||
if (unsortedPins.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pins = orderBy<Pin>(
|
||||
unsortedPins,
|
||||
[
|
||||
pin => pin.message.receivedAtCounter,
|
||||
pin => pin.message.sentAtTimestamp,
|
||||
],
|
||||
['ASC', 'ASC']
|
||||
);
|
||||
|
||||
const scroller = document.querySelector(
|
||||
'.module-timeline__messages__container'
|
||||
);
|
||||
@@ -334,7 +348,7 @@ function useTimelineIntersectionObserver(
|
||||
mutationObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}, [pins]);
|
||||
}, [unsortedPins]);
|
||||
}
|
||||
|
||||
export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() {
|
||||
@@ -352,12 +366,11 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() {
|
||||
const pins = useSelector(selectPins);
|
||||
const canPinMessages = getCanPinMessages(conversation);
|
||||
|
||||
const { pushPanelForConversation, scrollToMessage } =
|
||||
const { onPinnedMessageRemove, pushPanelForConversation, scrollToMessage } =
|
||||
useConversationsActions();
|
||||
const { onPinnedMessageRemove } = usePinnedMessagesActions();
|
||||
|
||||
const [current, setCurrent] = useState(() => {
|
||||
return pins.at(-1)?.id ?? null;
|
||||
return pins.at(0)?.id ?? null;
|
||||
});
|
||||
|
||||
const isCurrentOutOfDate = useMemo(() => {
|
||||
@@ -376,7 +389,7 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() {
|
||||
}, [current, pins]);
|
||||
|
||||
if (isCurrentOutOfDate) {
|
||||
setCurrent(pins.at(-1)?.id ?? null);
|
||||
setCurrent(pins.at(0)?.id ?? null);
|
||||
}
|
||||
|
||||
const handleCurrentChange = useCallback(
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import { getConversationByIdSelector } from '../selectors/conversations.dom.js';
|
||||
import {
|
||||
getConversationByIdSelector,
|
||||
getPinnedMessages,
|
||||
} from '../selectors/conversations.dom.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import { PinnedMessagesPanel } from '../../components/conversation/pinned-messages/PinnedMessagesPanel.dom.js';
|
||||
import type { SmartTimelineItemProps } from './TimelineItem.preload.js';
|
||||
import { SmartTimelineItem } from './TimelineItem.preload.js';
|
||||
import { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
|
||||
import { canPinMessages as getCanPinMessages } from '../selectors/message.preload.js';
|
||||
import { usePinnedMessagesActions } from '../ducks/pinnedMessages.preload.js';
|
||||
import { useConversationsActions } from '../ducks/conversations.preload.js';
|
||||
|
||||
export type SmartPinnedMessagesPanelProps = Readonly<{
|
||||
@@ -28,7 +29,8 @@ export const SmartPinnedMessagesPanel = memo(function SmartPinnedMessagesPanel(
|
||||
const i18n = useSelector(getIntl);
|
||||
const conversationSelector = useSelector(getConversationByIdSelector);
|
||||
const conversation = conversationSelector(props.conversationId);
|
||||
const { popPanelForConversation } = useConversationsActions();
|
||||
const { onPinnedMessageRemove, popPanelForConversation } =
|
||||
useConversationsActions();
|
||||
|
||||
strictAssert(
|
||||
conversation,
|
||||
@@ -38,11 +40,9 @@ export const SmartPinnedMessagesPanel = memo(function SmartPinnedMessagesPanel(
|
||||
const pinnedMessages = useSelector(getPinnedMessages);
|
||||
const canPinMessages = getCanPinMessages(conversation);
|
||||
|
||||
const { onPinnedMessageRemove } = usePinnedMessagesActions();
|
||||
|
||||
const handlePinnedMessageRemoveAll = useCallback(() => {
|
||||
popPanelForConversation();
|
||||
for (const { pinnedMessage } of pinnedMessages) {
|
||||
for (const pinnedMessage of pinnedMessages) {
|
||||
onPinnedMessageRemove(pinnedMessage.messageId);
|
||||
}
|
||||
}, [popPanelForConversation, pinnedMessages, onPinnedMessageRemove]);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getSafeConversationWithSameTitle,
|
||||
getSelectedConversationId,
|
||||
getTargetedMessage,
|
||||
getPinnedMessages,
|
||||
} from '../selectors/conversations.dom.js';
|
||||
import { getIntl, getTheme } from '../selectors/user.std.js';
|
||||
import type { PropsType as SmartCollidingAvatarsPropsType } from './CollidingAvatars.dom.js';
|
||||
@@ -48,7 +49,6 @@ import { SmartTypingBubble } from './TypingBubble.preload.js';
|
||||
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager.preload.js';
|
||||
import { isInFullScreenCall as getIsInFullScreenCall } from '../selectors/calling.std.js';
|
||||
import { SmartPinnedMessagesBar } from './PinnedMessagesBar.preload.js';
|
||||
import { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
|
||||
|
||||
const { isEmpty } = lodash;
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import { renderReactionPicker } from './renderReactionPicker.dom.js';
|
||||
import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation.dom.js';
|
||||
import { TargetedMessageSource } from '../ducks/conversationsEnums.std.js';
|
||||
import type { MessageInteractivity } from '../../components/conversation/Message.dom.js';
|
||||
import { usePinnedMessagesActions } from '../ducks/pinnedMessages.preload.js';
|
||||
|
||||
export type SmartTimelineItemProps = {
|
||||
containerElementRef: RefObject<HTMLElement>;
|
||||
@@ -132,6 +131,8 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
|
||||
kickOffAttachmentDownload,
|
||||
markAttachmentAsCorrupted,
|
||||
messageExpanded,
|
||||
onPinnedMessageAdd,
|
||||
onPinnedMessageRemove,
|
||||
openGiftBadge,
|
||||
pushPanelForConversation,
|
||||
retryDeleteForEveryone,
|
||||
@@ -151,9 +152,6 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
|
||||
toggleSelectMessage,
|
||||
} = useConversationsActions();
|
||||
|
||||
const { onPinnedMessageAdd, onPinnedMessageRemove } =
|
||||
usePinnedMessagesActions();
|
||||
|
||||
const {
|
||||
endPoll,
|
||||
reactToMessage,
|
||||
|
||||
@@ -32,7 +32,6 @@ import type { actions as megaphones } from './ducks/megaphones.preload.js';
|
||||
import type { actions as nav } from './ducks/nav.std.js';
|
||||
import type { actions as network } from './ducks/network.dom.js';
|
||||
import type { actions as notificationProfiles } from './ducks/notificationProfiles.preload.js';
|
||||
import type { actions as pinnedMessages } from './ducks/pinnedMessages.preload.js';
|
||||
import type { actions as safetyNumber } from './ducks/safetyNumber.preload.js';
|
||||
import type { actions as search } from './ducks/search.preload.js';
|
||||
import type { actions as stickers } from './ducks/stickers.preload.js';
|
||||
@@ -71,7 +70,6 @@ export type ReduxActions = {
|
||||
nav: typeof nav;
|
||||
network: typeof network;
|
||||
notificationProfiles: typeof notificationProfiles;
|
||||
pinnedMessages: typeof pinnedMessages;
|
||||
safetyNumber: typeof safetyNumber;
|
||||
search: typeof search;
|
||||
stickers: typeof stickers;
|
||||
|
||||
@@ -467,6 +467,7 @@ describe('both/state/ducks/conversations', () => {
|
||||
return {
|
||||
messageChangeCounter: 0,
|
||||
messageIds: [],
|
||||
pinnedMessages: [],
|
||||
metrics: {
|
||||
totalUnseen: 0,
|
||||
},
|
||||
@@ -1548,6 +1549,7 @@ describe('both/state/ducks/conversations', () => {
|
||||
},
|
||||
scrollToMessageCounter: 0,
|
||||
messageIds: [messageId, messageIdTwo, messageIdThree],
|
||||
pinnedMessages: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1628,6 +1630,7 @@ describe('both/state/ducks/conversations', () => {
|
||||
[conversationId]: {
|
||||
messageChangeCounter: 0,
|
||||
messageIds: [messageId, messageIdTwo, messageIdThree],
|
||||
pinnedMessages: [],
|
||||
metrics: {
|
||||
totalUnseen: 0,
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ export type PinnedMessage = Readonly<{
|
||||
|
||||
export type PinnedMessageParams = Omit<PinnedMessage, 'id'>;
|
||||
|
||||
export type PinnedMessageRenderData = Readonly<{
|
||||
export type PinnedMessagePreloadData = Readonly<{
|
||||
pinnedMessage: PinnedMessage;
|
||||
message: ReadonlyMessageAttributesType;
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user