mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Add pinned message chat event
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -311,6 +311,7 @@ const actions = () => ({
|
||||
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
||||
|
||||
openGiftBadge: action('openGiftBadge'),
|
||||
scrollToPinnedMessage: action('scrollToPinnedMessage'),
|
||||
scrollToPollMessage: action('scrollToPollMessage'),
|
||||
scrollToQuotedMessage: action('scrollToQuotedMessage'),
|
||||
showAttachmentDownloadStillInProgressToast: action(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
2
ts/model-types.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user