Add pinned message chat event

This commit is contained in:
Jamie
2025-11-17 12:44:14 -08:00
committed by GitHub
parent 1d8242bba6
commit 5bde700d4c
16 changed files with 208 additions and 4 deletions

View File

@@ -1514,6 +1514,10 @@
"messageformat": "No votes",
"description": "Message shown when poll has no votes"
},
"icu:Toast--PinnedMessageNotFound": {
"messageformat": "Pinned message not found",
"description": "Toast shown when user tries to view a pinned message that no longer exists"
},
"icu:Toast--PollNotFound": {
"messageformat": "Poll not found",
"description": "Toast shown when user tries to view a poll that no longer exists"
@@ -1594,6 +1598,18 @@
"messageformat": "Before you leave, you must choose at least one new admin for this group.",
"description": "Conversation Header > Leave Group Action > Cannot Leave Group Because You Are Last Admin Alert > Description"
},
"icu:PinnedMessageNotification__Message--You": {
"messageformat": "You pinned a message",
"description": "Conversation > Chat Event > Pinned Message > When you pinned a message"
},
"icu:PinnedMessageNotification__Message--SomeoneElse": {
"messageformat": "{sender} pinned a message",
"description": "Conversation > Chat Event > Pinned Message > When someone else pinned a message"
},
"icu:PinnedMessageNotification__Button": {
"messageformat": "Go to message",
"description": "Conversation > Chat Event > Pinned Message > Button to scroll to the pinned message"
},
"icu:sessionEnded": {
"messageformat": "Secure session reset",
"description": "This is a past tense, informational message. In other words, your secure session has been reset."

View File

@@ -189,6 +189,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.OriginalMessageNotFound };
case ToastType.PinnedConversationsFull:
return { toastType: ToastType.PinnedConversationsFull };
case ToastType.PinnedMessageNotFound:
return { toastType: ToastType.PinnedMessageNotFound };
case ToastType.PollNotFound:
return { toastType: ToastType.PollNotFound };
case ToastType.ReactionFailed:

View File

@@ -652,6 +652,14 @@ export function renderToast({
);
}
if (toastType === ToastType.PinnedMessageNotFound) {
return (
<Toast onClose={hideToast}>
{i18n('icu:Toast--PinnedMessageNotFound')}
</Toast>
);
}
if (toastType === ToastType.PollNotFound) {
return <Toast onClose={hideToast}>{i18n('icu:Toast--PollNotFound')}</Toast>;
}

View File

@@ -311,6 +311,7 @@ const actions = () => ({
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
openGiftBadge: action('openGiftBadge'),
scrollToPinnedMessage: action('scrollToPinnedMessage'),
scrollToPollMessage: action('scrollToPollMessage'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
showAttachmentDownloadStillInProgressToast: action(

View File

@@ -92,6 +92,7 @@ const getDefaultProps = () => ({
),
showMediaNoLongerAvailableToast: action('showMediaNoLongerAvailableToast'),
showTapToViewNotAvailableModal: action('showTapToViewNotAvailableModal'),
scrollToPinnedMessage: action('scrollToPinnedMessage'),
scrollToPollMessage: action('scrollToPollMessage'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
showSpoiler: action('showSpoiler'),
@@ -503,6 +504,20 @@ export function Notification(): JSX.Element {
conversation: getDefaultConversation(),
},
},
{
type: 'pinnedMessage',
data: {
sender: getDefaultConversation({ isMe: true }),
pinnedMessageId: '1',
},
},
{
type: 'pinnedMessage',
data: {
sender: getDefaultConversation({ isMe: false }),
pinnedMessageId: '1',
},
},
{
type: 'resetSessionNotification',
data: null,

View File

@@ -58,6 +58,8 @@ import type { PropsDataType as ConversationMergeNotificationPropsType } from './
import { ConversationMergeNotification } from './ConversationMergeNotification.dom.js';
import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from './PhoneNumberDiscoveryNotification.dom.js';
import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification.dom.js';
import type { PinnedMessageNotificationData } from './pinned-messages/PinnedMessageNotification.dom.js';
import { PinnedMessageNotification } from './pinned-messages/PinnedMessageNotification.dom.js';
import { SystemMessage } from './SystemMessage.dom.js';
import { TimelineMessage } from './TimelineMessage.dom.js';
import {
@@ -150,6 +152,10 @@ type PaymentEventType = {
type: 'paymentEvent';
data: Omit<PaymentEventNotificationPropsType, 'i18n'>;
};
type PinnedMessageNotificationType = {
type: 'pinnedMessage';
data: PinnedMessageNotificationData;
};
type MessageRequestResponseNotificationType = {
type: 'messageRequestResponse';
data: MessageRequestResponseNotificationData;
@@ -185,6 +191,7 @@ export type TimelineItemType = (
| UnsupportedMessageType
| VerificationNotificationType
| PaymentEventType
| PinnedMessageNotificationType
| MessageRequestResponseNotificationType
) & { timestamp: number };
@@ -197,11 +204,12 @@ type PropsLocalType = {
isGroup: boolean;
isNextItemCallingNotification: boolean;
isTargeted: boolean;
scrollToPinnedMessage: (pinnedMessageId: string) => void;
scrollToPollMessage: (messageId: string, conversationId: string) => unknown;
targetMessage: (messageId: string, conversationId: string) => unknown;
shouldRenderDateHeader: boolean;
onOpenEditNicknameAndNoteModal: (contactId: string) => void;
onOpenMessageRequestActionsConfirmation(state: MessageRequestState): void;
onOpenMessageRequestActionsConfirmation: (state: MessageRequestState) => void;
platform: string;
renderContact: SmartContactRendererType<JSX.Element>;
renderUniversalTimerNotification: () => JSX.Element;
@@ -246,6 +254,7 @@ export const TimelineItem = memo(function TimelineItem({
platform,
renderUniversalTimerNotification,
returnToActiveCall,
scrollToPinnedMessage,
scrollToPollMessage,
targetMessage,
setMessageToEdit,
@@ -424,6 +433,14 @@ export const TimelineItem = memo(function TimelineItem({
i18n={i18n}
/>
);
} else if (item.type === 'pinnedMessage') {
notification = (
<PinnedMessageNotification
{...item.data}
i18n={i18n}
onScrollToPinnedMessage={scrollToPinnedMessage}
/>
);
} else if (item.type === 'pollTerminate') {
notification = (
<PollTerminateNotification

View File

@@ -0,0 +1,58 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import type { ConversationType } from '../../../state/ducks/conversations.preload.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import { I18n } from '../../I18n.dom.js';
import { SystemMessage } from '../SystemMessage.dom.js';
import { UserText } from '../../UserText.dom.js';
import { Button, ButtonSize, ButtonVariant } from '../../Button.dom.js';
export type PinnedMessageNotificationData = Readonly<{
sender: ConversationType;
pinnedMessageId: string;
}>;
export type PinnedMessageNotificationProps = PinnedMessageNotificationData &
Readonly<{
i18n: LocalizerType;
onScrollToPinnedMessage: (messageId: string) => void;
}>;
export function PinnedMessageNotification(
props: PinnedMessageNotificationProps
): JSX.Element {
const { i18n, sender, pinnedMessageId, onScrollToPinnedMessage } = props;
const onClick = useCallback(() => {
onScrollToPinnedMessage(pinnedMessageId);
}, [onScrollToPinnedMessage, pinnedMessageId]);
return (
<SystemMessage
symbol="pin"
contents={
sender.isMe ? (
i18n('icu:PinnedMessageNotification__Message--You')
) : (
<I18n
id="icu:PinnedMessageNotification__Message--SomeoneElse"
components={{
sender: <UserText text={sender.title} />,
}}
i18n={i18n}
/>
)
}
button={
<Button
onClick={onClick}
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
>
{i18n('icu:PinnedMessageNotification__Button')}
</Button>
}
/>
);
}

2
ts/model-types.d.ts vendored
View File

@@ -165,6 +165,7 @@ type MessageType =
| 'joined-signal-notification'
| 'keychange'
| 'outgoing'
| 'pinned-message-notification'
| 'phone-number-discovery'
| 'poll-terminate'
| 'profile-change'
@@ -212,6 +213,7 @@ export type MessageAttributesType = {
payment?: AnyPaymentEvent;
quote?: QuotedMessageType;
reactions?: ReadonlyArray<MessageReactionType>;
pinnedMessageId?: string;
poll?: PollMessageAttribute;
pollTerminateNotification?: {
question: string;

View File

@@ -92,6 +92,7 @@ import {
isJoinedSignalNotification,
isTitleTransitionNotification,
isMessageRequestResponse,
isPinnedMessageNotification,
} from '../../state/selectors/message.preload.js';
import * as Bytes from '../../Bytes.std.js';
import { canBeSynced as canPreferredReactionEmojiBeSynced } from '../../reactions/preferredReactionEmoji.std.js';
@@ -1830,6 +1831,10 @@ export class BackupExportStream extends Readable {
return { kind: NonBubbleResultKind.Directionless, patch };
}
if (isPinnedMessageNotification(message)) {
throw new Error('unimplemented');
}
if (isProfileChange(message)) {
const profileChange = new Backups.ProfileChangeChatUpdate();
if (!message.profileChange?.newName || !message.profileChange?.oldName) {

View File

@@ -39,7 +39,7 @@ import {
import { LinkPreviewSourceType } from '../../types/LinkPreview.std.js';
import type { AciString } from '../../types/ServiceId.std.js';
import { completeRecording, getIsRecording } from './audioRecorder.preload.js';
import { SHOW_TOAST } from './toast.preload.js';
import { SHOW_TOAST, showToast } from './toast.preload.js';
import type { AnyToast } from '../../types/Toast.dom.js';
import { ToastType } from '../../types/Toast.dom.js';
import { SafetyNumberChangeSource } from '../../types/SafetyNumberChangeSource.std.js';
@@ -75,7 +75,10 @@ import { writeDraftAttachment } from '../../util/writeDraftAttachment.preload.js
import { getMessageById } from '../../messages/getMessageById.preload.js';
import { canReply, isNormalBubble } from '../selectors/message.preload.js';
import { getAuthorId } from '../../messages/sources.preload.js';
import { getConversationSelector } from '../selectors/conversations.dom.js';
import {
getConversationSelector,
getSelectedConversationId,
} from '../selectors/conversations.dom.js';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend.preload.js';
import { enqueuePollTerminateForSend } from '../../polls/enqueuePollTerminateForSend.preload.js';
import { useBoundActions } from '../../hooks/useBoundActions.std.js';
@@ -99,6 +102,7 @@ import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../util/GoogleChrome.std.js';
import type { StateThunk } from '../types.std.js';
const { debounce, isEqual } = lodash;
@@ -255,6 +259,7 @@ export const actions = {
replaceAttachments,
resetComposer,
saveDraftRecordingIfNeeded,
scrollToPinnedMessage,
scrollToPollMessage,
scrollToQuotedMessage,
sendEditedMessage,
@@ -377,6 +382,32 @@ function scrollToQuotedMessage({
};
}
function scrollToPinnedMessage(
pinnedMessageId: string
): StateThunk<ShowToastActionType | ScrollToMessageActionType> {
return async (dispatch, getState) => {
const pinnedMessage = await getMessageById(pinnedMessageId);
if (!pinnedMessage) {
dispatch(
showToast({
toastType: ToastType.PinnedMessageNotFound,
})
);
return;
}
const selectedConversationId = getSelectedConversationId(getState());
const pinnedMessageConversationId = pinnedMessage.get('conversationId');
if (selectedConversationId !== pinnedMessageConversationId) {
return;
}
dispatch(scrollToMessage(pinnedMessageConversationId, pinnedMessageId));
};
}
function scrollToPollMessage(
pollMessageId: string,
conversationId: string

View File

@@ -165,6 +165,7 @@ import { CallMode, CallDirection } from '../../types/CallDisposition.std.js';
import { getCallIdFromEra } from '../../util/callDisposition.preload.js';
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';
const { groupBy, isEmpty, isNumber, isObject, map } = lodash;
@@ -1107,6 +1108,13 @@ export function getPropsForBubble(
timestamp,
};
}
if (isPinnedMessageNotification(message)) {
return {
type: 'pinnedMessage',
data: getPropsForPinnedMessageNotification(message, options),
timestamp,
};
}
if (isProfileChange(message)) {
return {
type: 'profileChange',
@@ -1227,6 +1235,7 @@ export function isNormalBubble(message: MessageWithUIFieldsType): boolean {
!isKeyChange(message) &&
!isPhoneNumberDiscovery(message) &&
!isTitleTransitionNotification(message) &&
!isPinnedMessageNotification(message) &&
!isProfileChange(message) &&
!isUniversalTimerNotification(message) &&
!isUnsupportedMessage(message) &&
@@ -1708,6 +1717,25 @@ export function getPropsForCallHistory(
};
}
// Pinned Message Notification
export function isPinnedMessageNotification(
message: MessageWithUIFieldsType
): boolean {
return message.type === 'pinned-message-notification';
}
export function getPropsForPinnedMessageNotification(
message: MessageWithUIFieldsType,
{ conversationSelector }: GetPropsForBubbleOptions
): PinnedMessageNotificationData {
strictAssert(message.pinnedMessageId, 'Missing pinnedMessageId');
return {
sender: conversationSelector(message.sourceServiceId),
pinnedMessageId: message.pinnedMessageId,
};
}
// Profile Change
export function isProfileChange(message: MessageWithUIFieldsType): boolean {

View File

@@ -150,6 +150,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
const {
endPoll,
reactToMessage,
scrollToPinnedMessage,
scrollToPollMessage,
scrollToQuotedMessage,
setQuoteByMessageId,
@@ -232,6 +233,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
scrollToPinnedMessage={scrollToPinnedMessage}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
sendPollVote={sendPollVote}

View File

@@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { Selector } from 'reselect';
import type { ThunkAction } from 'redux-thunk';
import type { Action } from 'redux';
import type { StateType } from './reducer.preload.js';
import type { actions as accounts } from './ducks/accounts.preload.js';
import type { actions as app } from './ducks/app.preload.js';
@@ -76,3 +78,9 @@ export type ReduxActions = {
};
export type StateSelector<T> = Selector<StateType, T>;
export type StateThunk<A extends Action = never> = ThunkAction<
void,
StateType,
unknown,
A
>;

View File

@@ -65,6 +65,7 @@ export enum ToastType {
NotificationProfileUpdate = 'NotificationProfileUpdate',
OriginalMessageNotFound = 'OriginalMessageNotFound',
PinnedConversationsFull = 'PinnedConversationsFull',
PinnedMessageNotFound = 'PinnedMessageNotFound',
PollNotFound = 'PollNotFound',
ReactionFailed = 'ReactionFailed',
ReceiptSaved = 'ReceiptSaved',
@@ -197,6 +198,7 @@ export type AnyToast =
}
| { toastType: ToastType.OriginalMessageNotFound }
| { toastType: ToastType.PinnedConversationsFull }
| { toastType: ToastType.PinnedMessageNotFound }
| { toastType: ToastType.PollNotFound }
| { toastType: ToastType.ReactionFailed }
| {

View File

@@ -51,6 +51,7 @@ import {
isUnsupportedMessage,
isConversationMerge,
isMessageRequestResponse,
isPinnedMessageNotification,
} from '../state/selectors/message.preload.js';
import { getAuthor } from '../messages/sources.preload.js';
import {
@@ -510,6 +511,10 @@ export function getNotificationDataForMessage(
};
}
if (isPinnedMessageNotification(attributes)) {
throw new Error('unimplemented');
}
const { poll } = attributes;
if (poll) {
return {

View File

@@ -15,6 +15,7 @@ import {
isGroupUpdate,
isGroupV2Change,
isKeyChange,
isPinnedMessageNotification,
isPhoneNumberDiscovery,
isProfileChange,
isTapToView,
@@ -59,6 +60,8 @@ export function isMessageEmpty(attributes: MessageAttributesType): boolean {
const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes);
const isTitleTransitionNotificationValue =
isTitleTransitionNotification(attributes);
const isPinnedMessageNotificationValue =
isPinnedMessageNotification(attributes);
const isPayment = messageHasPaymentEvent(attributes);
@@ -93,7 +96,8 @@ export function isMessageEmpty(attributes: MessageAttributesType): boolean {
isUniversalTimerNotificationValue ||
isConversationMergeValue ||
isPhoneNumberDiscoveryValue ||
isTitleTransitionNotificationValue;
isTitleTransitionNotificationValue ||
isPinnedMessageNotificationValue;
return !hasSomethingToDisplay;
}