Setup pin/unpin actions and mark messages pinned in timeline

This commit is contained in:
Jamie
2025-12-10 14:01:17 -08:00
committed by GitHub
parent 1338eadf6f
commit db4845100a
32 changed files with 504 additions and 298 deletions
+8
View File
@@ -1470,6 +1470,10 @@
"messageformat": "Pin message",
"description": "Shown on the drop-down menu for an individual message, pins the current message"
},
"icu:MessageContextMenu__UnpinMessage": {
"messageformat": "Unpin message",
"description": "Shown on the drop-down menu for an individual message, unpins the current message"
},
"icu:Poll__end-poll": {
"messageformat": "End poll",
"description": "Label for button/menu item to end a poll. Shown in the poll votes modal and in the message context menu"
@@ -9266,6 +9270,10 @@
"messageformat": "Edited",
"description": "label for an edited message"
},
"icu:MessageMetadata__pinned": {
"messageformat": "Pinned",
"description": "label for an pinned message"
},
"icu:DraftGifMessageSendModal__Title": {
"messageformat": "Add a message",
"description": "Draft GIF Message Send Modal > Title"
+36 -139
View File
@@ -1193,12 +1193,34 @@ $message-padding-horizontal: 12px;
}
.module-message__metadata {
@include mixins.font-caption;
align-items: center;
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 3px;
font-style: normal;
user-select: none;
white-space: nowrap;
color: light-dark(variables.$color-gray-60, variables.$color-gray-25);
&--outgoing {
color: variables.$color-white-alpha-80;
}
&--with-image-no-caption {
color: light-dark(variables.$color-white, variables.$color-white-alpha-80);
}
&--outline-only-bubble {
color: light-dark(variables.$color-gray-60, variables.$color-gray-25);
}
&--sticker {
color: variables.$color-gray-60;
}
&--inline {
float: inline-end;
@@ -1211,43 +1233,14 @@ $message-padding-horizontal: 12px;
.module-message__metadata__edited {
@include mixins.button-reset;
& {
@include mixins.font-caption;
color: variables.$color-gray-60;
cursor: pointer;
margin-inline-end: 6px;
z-index: variables.$z-index-base;
}
@include mixins.dark-theme {
color: variables.$color-gray-25;
}
}
.module-message__metadata__sms {
width: 12px;
height: 12px;
display: inline-block;
margin-inline-start: 6px;
// High margin to leave space for the increase when we go to two checks
margin-bottom: 2px;
@include mixins.color-svg(
'../images/icons/v2/lock-unlock-outline-12.svg',
variables.$color-white
);
}
.module-message__metadata__sms--incoming {
@include mixins.light-theme {
background-color: variables.$color-gray-60;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-25;
}
}
.module-message__container--outgoing .module-message__metadata__edited {
color: variables.$color-white-alpha-80;
}
// With an image and no caption, this section needs to be on top of the image overlay
@@ -1265,62 +1258,9 @@ $message-padding-horizontal: 12px;
pointer-events: none;
}
.module-message__metadata--outline-only-bubble {
@include mixins.light-theme {
color: variables.$color-gray-60;
}
@include mixins.dark-theme {
color: variables.$color-gray-25;
}
}
.module-message__metadata__date {
@include mixins.font-caption;
user-select: none;
white-space: nowrap;
@include mixins.light-theme {
color: variables.$color-white-alpha-80;
}
@include mixins.dark-theme {
color: variables.$color-white-alpha-80;
}
}
.module-message__metadata__tapable {
@include mixins.button-reset;
}
.module-message__metadata__date--incoming {
color: variables.$color-white-alpha-80;
@include mixins.light-theme {
color: variables.$color-gray-60;
}
@include mixins.dark-theme {
color: variables.$color-gray-25;
}
}
.module-message__metadata__date--with-image-no-caption {
@include mixins.light-theme {
color: variables.$color-white;
}
@include mixins.dark-theme {
color: variables.$color-white-alpha-80;
}
}
.module-message__metadata__date--outline-only-bubble {
@include mixins.light-theme {
color: variables.$color-gray-60;
}
@include mixins.dark-theme {
color: variables.$color-gray-25;
}
}
.module-message__metadata__date--with-sticker {
@include mixins.light-theme {
color: variables.$color-gray-60;
}
}
.module-message__metadata__status-icon {
width: 12px;
@@ -1338,41 +1278,25 @@ $message-padding-horizontal: 12px;
}
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-sending.svg',
variables.$color-white
currentColor
);
}
.module-message__metadata__status-icon--sent {
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-sent.svg',
variables.$color-white-alpha-80
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-sent.svg',
variables.$color-white-alpha-80
);
}
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-sent.svg',
currentColor
);
}
.module-message__metadata__status-icon--delivered {
// We reduce the margin size to keep the overall width the same
margin-inline-end: 0px;
width: 18px;
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-delivered.svg',
variables.$color-white-alpha-80
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-delivered.svg',
variables.$color-white-alpha-80
);
}
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-delivered.svg',
currentColor
);
}
.module-message__metadata__status-icon--read,
.module-message__metadata__status-icon--viewed {
@@ -1380,42 +1304,15 @@ $message-padding-horizontal: 12px;
margin-inline-end: 0px;
width: 18px;
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-read.svg',
variables.$color-white-alpha-80
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-read.svg',
variables.$color-white-alpha-80
);
}
@include mixins.color-svg(
'../images/icons/v3/message_status/messagestatus-read.svg',
currentColor
);
}
// When status indicators are overlaid on top of an image, they use different colors
.module-message__metadata__status-icon--with-image-no-caption {
@include mixins.dark-theme {
background-color: variables.$color-gray-02;
}
@include mixins.light-theme {
background-color: variables.$color-white;
}
}
.module-message__metadata__status-icon--with-sticker {
@include mixins.light-theme {
background-color: variables.$color-gray-60;
}
}
.module-message__metadata__status-icon--outline-only-bubble {
@include mixins.light-theme {
background-color: variables.$color-gray-60;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-25;
}
color: light-dark(variables.$color-white, variables.$color-gray-02);
}
.module-message__metadata__spinner-container {
+1 -1
View File
@@ -266,7 +266,7 @@
--type-text-body-large: 0.875rem /* 14px */;
--type-text-body-medium: 0.8125rem /* 13px */;
--type-text-body-small: 0.75rem /* 12px */;
--type-text-caption: 0.6825rem /* 11px */;
--type-text-caption: 0.6875rem /* 11px */;
/* font-weight */
--font-weight-*: initial; /* reset defaults */
--font-weight-semibold: 600;
+13
View File
@@ -73,12 +73,24 @@ export namespace AxoDialog {
export type ContentProps = Readonly<{
size: ContentSize;
escape: ContentEscape;
disableMissingAriaDescriptionWarning?: boolean;
children: ReactNode;
}>;
export const Content: FC<ContentProps> = memo(props => {
const sizeConfig = ContentSizes[props.size];
const handleContentEscapeEvent = useContentEscapeBehavior(props.escape);
const descriptionProps = useMemo((): Dialog.DialogContentProps => {
if (props.disableMissingAriaDescriptionWarning) {
// Generally you should just add a description with `AxoDialog.Description`
// and use `sr-only` to hide it if you don't want it to be visible
// https://www.radix-ui.com/primitives/docs/components/dialog#description
return { 'aria-describedby': undefined };
}
return {};
}, [props.disableMissingAriaDescriptionWarning]);
return (
<Dialog.Portal>
<Dialog.Overlay className={AxoBaseDialog.overlayStyles}>
@@ -90,6 +102,7 @@ export namespace AxoDialog {
width: sizeConfig.width,
minWidth: 320,
}}
{...descriptionProps}
>
{props.children}
</Dialog.Content>
+10
View File
@@ -2985,6 +2985,11 @@ export async function startApp(): Promise<void> {
}
if (data.message.pinMessage != null) {
if (!isPinnedMessagesReceiveEnabled()) {
log.warn('Dropping PinMessage because the flag is disabled');
confirm();
return;
}
strictAssert(data.timestamp != null, 'Missing sent timestamp');
await PinnedMessages.onPinnedMessageAdd({
targetSentTimestamp: data.message.pinMessage.targetSentTimestamp,
@@ -3129,6 +3134,11 @@ export async function startApp(): Promise<void> {
}
if (data.message.unpinMessage != null) {
if (!isPinnedMessagesReceiveEnabled()) {
log.warn('Dropping UnpinMessage because the flag is disabled');
confirm();
return;
}
await PinnedMessages.onPinnedMessageRemove({
targetSentTimestamp: data.message.unpinMessage.targetSentTimestamp,
targetAuthorAci: data.message.unpinMessage.targetAuthorAci,
@@ -59,6 +59,7 @@ const MESSAGE_DEFAULT_PROPS = {
doubleCheckMissingQuoteReference: shouldNeverBeCalled,
isBlocked: false,
isMessageRequestAccepted: true,
isPinned: false,
isSelected: false,
isSelectMode: false,
isSMS: false,
@@ -85,6 +85,7 @@ export const CallingNotification: React.FC<PropsType> = React.memo(
onForward={null}
onMoreInfo={null}
onPinMessage={null}
onUnpinMessage={null}
>
<div
// @ts-expect-error -- React/TS doesn't know about inert
+18 -6
View File
@@ -126,6 +126,7 @@ import {
} from '../fun/data/emojis.std.js';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.dom.js';
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
import type { RenderAudioAttachmentProps } from '../../state/smart/renderAudioAttachment.preload.js';
const { drop, take, unescape } = lodash;
@@ -326,6 +327,8 @@ export type PropsData = {
isTapToViewExpired?: boolean;
isTapToViewError?: boolean;
isPinned: boolean;
readStatus?: ReadStatus;
expirationLength?: number;
@@ -361,7 +364,7 @@ export type PropsHousekeeping = {
interactivity: MessageInteractivity;
interactionMode: InteractionModeType;
platform: string;
renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element;
renderAudioAttachment: (props: RenderAudioAttachmentProps) => JSX.Element;
shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean;
shouldHideMetadata: boolean;
@@ -826,6 +829,7 @@ export class Message extends React.PureComponent<Props, State> {
expirationTimestamp,
giftBadge,
i18n,
isPinned,
isTapToView,
isTapToViewError,
isTapToViewExpired,
@@ -840,6 +844,7 @@ export class Message extends React.PureComponent<Props, State> {
if (
!expirationLength &&
!expirationTimestamp &&
!isPinned &&
(!status || SENT_STATUSES.has(status)) &&
shouldHideMetadata
) {
@@ -1084,6 +1089,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n,
id,
isEditedMessage,
isPinned,
isSMS,
isSticker,
retryMessageSend,
@@ -1107,6 +1113,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n={i18n}
id={id}
isEditedMessage={isEditedMessage}
isPinned={isPinned}
isSMS={isSMS}
isInline={isInline}
isOutlineOnlyBubble={
@@ -1153,12 +1160,12 @@ export class Message extends React.PureComponent<Props, State> {
attachmentDroppedDueToSize,
attachments,
cancelAttachmentDownload,
conversationId,
direction,
expirationLength,
expirationTimestamp,
i18n,
id,
isPinned,
isSticker,
isVoiceMessagePlayed,
kickOffAttachmentDownload,
@@ -1301,7 +1308,6 @@ export class Message extends React.PureComponent<Props, State> {
i18n,
buttonRef: this.audioButtonRef,
renderingContext,
theme,
attachment: firstAttachment,
collapseMetadata,
withContentAbove,
@@ -1311,9 +1317,8 @@ export class Message extends React.PureComponent<Props, State> {
expirationLength,
expirationTimestamp,
id,
conversationId,
isPinned,
played: isVoiceMessagePlayed,
pushPanelForConversation,
status,
textPending: textAttachment?.pending,
timestamp,
@@ -1336,7 +1341,10 @@ export class Message extends React.PureComponent<Props, State> {
const isIncoming = direction === 'incoming';
const willShowMetadata =
expirationLength || expirationTimestamp || !shouldHideMetadata;
expirationLength ||
expirationTimestamp ||
isPinned ||
!shouldHideMetadata;
// Note: this has to be interactive for the case where text comes along with the
// attachment. But we don't want the user to tab here unless that text exists.
@@ -1426,6 +1434,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n={i18n}
id={id}
isEditedMessage={false}
isPinned={isPinned}
isSMS={false}
isInline={false}
isOutlineOnlyBubble={false}
@@ -2699,6 +2708,7 @@ export class Message extends React.PureComponent<Props, State> {
expirationTimestamp,
i18n,
id,
isPinned,
isTapToViewError,
isTapToViewExpired,
pushPanelForConversation,
@@ -2763,6 +2773,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n={i18n}
id={id}
isEditedMessage={false}
isPinned={isPinned}
isSMS={false}
isInline={false}
isOutlineOnlyBubble={false}
@@ -2804,6 +2815,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n={i18n}
id={id}
isEditedMessage={false}
isPinned={isPinned}
isSMS={false}
isInline={false}
isOutlineOnlyBubble={false}
@@ -49,6 +49,7 @@ export type OwnProps = Readonly<{
expirationLength?: number;
expirationTimestamp?: number;
id: string;
isPinned: boolean;
played: boolean;
status?: MessageStatusType;
textPending?: boolean;
@@ -156,6 +157,7 @@ export function MessageAudio(props: Props): JSX.Element {
expirationLength,
expirationTimestamp,
id,
isPinned,
played,
status,
textPending,
@@ -381,6 +383,7 @@ export function MessageAudio(props: Props): JSX.Element {
hasText={withContentBelow}
i18n={i18n}
id={id}
isPinned={isPinned}
isShowingImage={false}
isSticker={false}
pushPanelForConversation={pushPanelForConversation}
@@ -23,6 +23,7 @@ type MessageContextMenuProps = Readonly<{
onForward: (() => void) | null;
onDeleteMessage: (() => void) | null;
onPinMessage: (() => void) | null;
onUnpinMessage: (() => void) | null;
onMoreInfo: (() => void) | null;
onSelect: (() => void) | null;
children: ReactNode;
@@ -47,6 +48,7 @@ export function MessageContextMenu({
onForward,
onDeleteMessage,
onPinMessage,
onUnpinMessage,
children,
}: MessageContextMenuProps): JSX.Element {
return (
@@ -104,6 +106,11 @@ export function MessageContextMenu({
{i18n('icu:MessageContextMenu__PinMessage')}
</AxoMenuBuilder.Item>
)}
{isPinnedMessagesReceiveEnabled() && onUnpinMessage && (
<AxoMenuBuilder.Item symbol="pin-slash" onSelect={onUnpinMessage}>
{i18n('icu:MessageContextMenu__UnpinMessage')}
</AxoMenuBuilder.Item>
)}
{onMoreInfo && (
<AxoMenuBuilder.Item symbol="info" onSelect={onMoreInfo}>
{i18n('icu:MessageContextMenu__info')}
@@ -32,6 +32,7 @@ const defaultMessage: MessageDataPropsType = {
renderMenu: undefined,
isBlocked: false,
isMessageRequestAccepted: true,
isPinned: false,
isSelected: false,
isSelectMode: false,
isSMS: false,
@@ -18,6 +18,8 @@ import { ConfirmationDialog } from '../ConfirmationDialog.dom.js';
import { refMerger } from '../../util/refMerger.std.js';
import type { Size } from '../../hooks/useSizeObserver.dom.js';
import { SizeObserver } from '../../hooks/useSizeObserver.dom.js';
import { AxoSymbol } from '../../axo/AxoSymbol.dom.js';
import { tw } from '../../axo/tw.dom.js';
type PropsType = {
deletedForEveryone?: boolean;
@@ -28,6 +30,7 @@ type PropsType = {
i18n: LocalizerType;
id: string;
isEditedMessage?: boolean;
isPinned: boolean;
isSMS?: boolean;
isInline?: boolean;
isOutlineOnlyBubble?: boolean;
@@ -57,6 +60,7 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
i18n,
id,
isEditedMessage,
isPinned,
isSMS,
isOutlineOnlyBubble,
isInline,
@@ -135,19 +139,7 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
}
timestampNode = (
<span
className={classNames({
'module-message__metadata__date': true,
'module-message__metadata__date--with-sticker': isSticker,
'module-message__metadata__date--outline-only-bubble':
isOutlineOnlyBubble,
[`module-message__metadata__date--${direction}`]: !isSticker,
'module-message__metadata__date--with-image-no-caption':
withImageNoCaption,
})}
>
{statusInfo}
</span>
<span className="module-message__metadata__date">{statusInfo}</span>
);
} else {
timestampNode = (
@@ -195,12 +187,22 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
const className = classNames(
'module-message__metadata',
direction === 'outgoing' && 'module-message__metadata--outgoing',
isInline && 'module-message__metadata--inline',
withImageNoCaption && 'module-message__metadata--with-image-no-caption',
isOutlineOnlyBubble && 'module-message__metadata--outline-only-bubble'
isOutlineOnlyBubble && 'module-message__metadata--outline-only-bubble',
isSticker && 'module-message__metadata--sticker'
);
const children = (
<>
{isPinned && (
<span className={tw('me-1')}>
<AxoSymbol.InlineGlyph
symbol="pin-fill"
label={i18n('icu:MessageMetadata__pinned')}
/>
</span>
)}
{isEditedMessage && showEditHistoryModal && (
<button
className="module-message__metadata__edited"
@@ -212,9 +214,9 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
)}
{timestampNode}
{isSMS ? (
<div
className={`module-message__metadata__sms module-message__metadata__sms--${direction}`}
/>
<div className="module-message__metadata__sms">
<AxoSymbol.InlineGlyph symbol="lock-open" label={null} />
</div>
) : null}
{expirationLength ? (
<ExpireTimer
@@ -239,16 +241,7 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
<div
className={classNames(
'module-message__metadata__status-icon',
`module-message__metadata__status-icon--${status}`,
isSticker
? 'module-message__metadata__status-icon--with-sticker'
: null,
withImageNoCaption
? 'module-message__metadata__status-icon--with-image-no-caption'
: null,
isOutlineOnlyBubble
? 'module-message__metadata__status-icon--outline-only-bubble'
: null
`module-message__metadata__status-icon--${status}`
)}
/>
) : null}
@@ -77,6 +77,7 @@ const defaultMessageProps: TimelineMessagesProps = {
canEditMessage: true,
canEndPoll: false,
canForward: true,
canPinMessages: true,
canReact: true,
canReply: true,
canRetry: true,
@@ -105,6 +106,7 @@ const defaultMessageProps: TimelineMessagesProps = {
interactionMode: 'keyboard',
isBlocked: false,
isMessageRequestAccepted: true,
isPinned: false,
isSelected: false,
isSelectMode: false,
isSMS: false,
@@ -137,6 +139,8 @@ const defaultMessageProps: TimelineMessagesProps = {
shouldCollapseBelow: false,
shouldHideMetadata: false,
showSpoiler: action('showSpoiler'),
onPinnedMessageAdd: action('onPinnedMessageAdd'),
onPinnedMessageRemove: action('onPinnedMessageRemove'),
pushPanelForConversation: action('default--pushPanelForConversation'),
showContactModal: action('default--showContactModal'),
showAttachmentDownloadStillInProgressToast: action(
@@ -55,6 +55,7 @@ function mockMessageTimelineItem(
canEditMessage: true,
canEndPoll: false,
canForward: true,
canPinMessages: true,
canReact: true,
canReply: true,
canRetry: true,
@@ -67,6 +68,7 @@ function mockMessageTimelineItem(
text: 'Hello there from the new world!',
isBlocked: false,
isMessageRequestAccepted: true,
isPinned: false,
isSelected: false,
isSelectMode: false,
isSMS: false,
@@ -312,6 +314,8 @@ const actions = () => ({
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
openGiftBadge: action('openGiftBadge'),
onPinnedMessageAdd: action('onPinnedMessageAdd'),
onPinnedMessageRemove: action('onPinnedMessageRemove'),
scrollToPinnedMessage: action('scrollToPinnedMessage'),
scrollToPollMessage: action('scrollToPollMessage'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
@@ -41,6 +41,7 @@ const getDefaultProps = () => ({
getPreferredBadge: () => undefined,
id: 'asdf',
isNextItemCallingNotification: false,
isPinned: false,
isTargeted: false,
isBlocked: false,
isGroup: false,
@@ -69,6 +70,8 @@ const getDefaultProps = () => ({
openGiftBadge: action('openGiftBadge'),
saveAttachment: action('saveAttachment'),
saveAttachments: action('saveAttachments'),
onPinnedMessageAdd: action('onPinnedMessageAdd'),
onPinnedMessageRemove: action('onPinnedMessageRemove'),
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation'
@@ -9,7 +9,6 @@ import type { Meta, StoryFn } from '@storybook/react';
import { SignalService } from '../../protobuf/index.std.js';
import { ConversationColors } from '../../types/Colors.std.js';
import type { AudioAttachmentProps } from './Message.dom.js';
import type { Props } from './TimelineMessage.dom.js';
import { TimelineMessage } from './TimelineMessage.dom.js';
import { MessageInteractivity, TextDirection } from './Message.dom.js';
@@ -43,6 +42,7 @@ import { getFakeBadge } from '../../test-helpers/getFakeBadge.std.js';
import { ThemeType } from '../../types/Util.std.js';
import { BadgeCategory } from '../../badges/BadgeCategory.std.js';
import { PaymentEventKind } from '../../types/Payment.std.js';
import type { RenderAudioAttachmentProps } from '../../state/smart/renderAudioAttachment.preload.js';
const { isBoolean, noop } = lodash;
@@ -117,7 +117,7 @@ const renderReactionPicker: Props['renderReactionPicker'] = () => <div />;
function MessageAudioContainer({
played,
...props
}: AudioAttachmentProps): JSX.Element {
}: RenderAudioAttachmentProps): JSX.Element {
const [isActive, setIsActive] = React.useState<boolean>(false);
const [currentTime, setCurrentTime] = React.useState<number>(0);
const [playbackRate, setPlaybackRate] = React.useState<number>(1);
@@ -214,6 +214,7 @@ function MessageAudioContainer({
{...props}
active={active}
computePeaks={computePeaks}
isPinned={false}
onPlayMessage={handlePlayMessage}
played={_played}
pushPanelForConversation={action('pushPanelForConversation')}
@@ -236,6 +237,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
canCopy: true,
canEditMessage: true,
canEndPoll: overrideProps.direction === 'outgoing',
canPinMessages: overrideProps.canPinMessages ?? true,
canReact: true,
canReply: true,
canDownload: true,
@@ -276,6 +278,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isMessageRequestAccepted: isBoolean(overrideProps.isMessageRequestAccepted)
? overrideProps.isMessageRequestAccepted
: true,
isPinned: isBoolean(overrideProps.isPinned) ? overrideProps.isPinned : false,
isSelected: isBoolean(overrideProps.isSelected)
? overrideProps.isSelected
: false,
@@ -294,6 +297,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
messageExpanded: action('messageExpanded'),
showConversation: action('showConversation'),
openGiftBadge: action('openGiftBadge'),
onPinnedMessageAdd: action('onPinnedMessageAdd'),
onPinnedMessageRemove: action('onPinnedMessageRemove'),
previews: overrideProps.previews || [],
quote: overrideProps.quote || undefined,
reactions: overrideProps.reactions,
@@ -3288,3 +3293,9 @@ AttachmentWithError.args = {
],
status: 'sent',
};
export const PinnedMessages = Template.bind({});
PinnedMessages.args = {
text: 'I am pinned',
isPinned: true,
};
@@ -37,6 +37,7 @@ import { isNotNil } from '../../util/isNotNil.std.js';
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js';
import { PinMessageDialog } from './pinned-messages/PinMessageDialog.dom.js';
import type { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js';
import { useDocumentKeyDown } from '../../hooks/useDocumentKeyDown.dom.js';
const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu;
@@ -53,11 +54,17 @@ export type PropsData = {
canRetryDeleteForEveryone: boolean;
canReact: boolean;
canReply: boolean;
canPinMessages: boolean;
selectedReaction?: string;
isTargeted?: boolean;
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
export type PropsActions = {
onPinnedMessageAdd: (
messageId: string,
duration: DurationInSeconds | null
) => void;
onPinnedMessageRemove: (messageId: string) => void;
pushPanelForConversation: PushPanelForConversationActionType;
toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void;
toggleForwardMessagesModal: (payload: ForwardMessagesPayload) => void;
@@ -106,6 +113,7 @@ export function TimelineMessage(props: Props): JSX.Element {
canReply,
canRetry,
canRetryDeleteForEveryone,
canPinMessages,
containerElementRef,
containerWidthBreakpoint,
conversationId,
@@ -113,10 +121,13 @@ export function TimelineMessage(props: Props): JSX.Element {
i18n,
id,
interactivity,
isPinned,
isTargeted,
kickOffAttachmentDownload,
copyMessageText,
endPoll,
onPinnedMessageAdd,
onPinnedMessageRemove,
pushPanelForConversation,
reactToMessage,
renderReactionPicker,
@@ -277,6 +288,18 @@ export function TimelineMessage(props: Props): JSX.Element {
setPinMessageDialogOpen(true);
}, []);
const handlePinnedMessageAdd = useCallback(
(messageId: string, duration: DurationInSeconds | null) => {
onPinnedMessageAdd(messageId, duration);
setPinMessageDialogOpen(false);
},
[onPinnedMessageAdd]
);
const handleUnpinMessage = useCallback(() => {
onPinnedMessageRemove(id);
}, [onPinnedMessageRemove, id]);
const toggleReactionPickerKeyboard = useToggleReactionPicker(
handleReact || noop
);
@@ -339,7 +362,12 @@ export function TimelineMessage(props: Props): JSX.Element {
messageIds: [id],
});
}}
onPinMessage={handleOpenPinMessageDialog}
onPinMessage={
canPinMessages && !isPinned ? handleOpenPinMessageDialog : null
}
onUnpinMessage={
canPinMessages && isPinned ? handleUnpinMessage : null
}
onMoreInfo={() =>
pushPanelForConversation({
type: PanelType.MessageDetails,
@@ -355,6 +383,7 @@ export function TimelineMessage(props: Props): JSX.Element {
canCopy,
canEditMessage,
canForward,
canPinMessages,
canRetry,
canSelect,
canEndPoll,
@@ -364,10 +393,12 @@ export function TimelineMessage(props: Props): JSX.Element {
handleDownload,
handleReact,
handleOpenPinMessageDialog,
handleUnpinMessage,
endPoll,
handleReplyToMessage,
i18n,
id,
isPinned,
pushPanelForConversation,
retryDeleteForEveryone,
retryMessageSend,
@@ -461,10 +492,7 @@ export function TimelineMessage(props: Props): JSX.Element {
messageId={id}
open={pinMessageDialogOpen}
onOpenChange={setPinMessageDialogOpen}
onPinMessage={() => {
// TODO
setPinMessageDialogOpen(false);
}}
onPinnedMessageAdd={handlePinnedMessageAdd}
/>
</>
);
@@ -19,7 +19,7 @@ export function Default(): JSX.Element {
open={open}
onOpenChange={setOpen}
messageId="42"
onPinMessage={action('onPinMessage')}
onPinnedMessageAdd={action('onPinnedMessageAdd')}
/>
);
}
@@ -7,21 +7,18 @@ import { AxoRadioGroup } from '../../../axo/AxoRadioGroup.dom.js';
import { DurationInSeconds } from '../../../util/durations/duration-in-seconds.std.js';
import { strictAssert } from '../../../util/assert.std.js';
export enum DurationOption {
enum DurationOption {
TIME_24_HOURS = 'TIME_24_HOURS',
TIME_7_DAYS = 'TIME_7_DAYS',
TIME_30_DAYS = 'TIME_30_DAYS',
FOREVER = 'FOREVER',
}
export type DurationValue =
| { seconds: number; forever?: never }
| { seconds?: never; forever: true };
const DURATION_OPTIONS: Record<DurationOption, DurationValue> = {
[DurationOption.TIME_24_HOURS]: { seconds: DurationInSeconds.fromHours(24) },
[DurationOption.TIME_7_DAYS]: { seconds: DurationInSeconds.fromDays(7) },
[DurationOption.TIME_30_DAYS]: { seconds: DurationInSeconds.fromDays(30) },
[DurationOption.FOREVER]: { forever: true },
const DURATION_OPTIONS: Record<DurationOption, DurationInSeconds | null> = {
[DurationOption.TIME_24_HOURS]: DurationInSeconds.fromHours(24),
[DurationOption.TIME_7_DAYS]: DurationInSeconds.fromDays(7),
[DurationOption.TIME_30_DAYS]: DurationInSeconds.fromDays(30),
[DurationOption.FOREVER]: null,
};
function isValidDurationOption(value: string): value is DurationOption {
@@ -33,13 +30,16 @@ export type PinMessageDialogProps = Readonly<{
open: boolean;
onOpenChange: (open: boolean) => void;
messageId: string;
onPinMessage: (messageId: string, duration: DurationValue) => void;
onPinnedMessageAdd: (
messageId: string,
duration: DurationInSeconds | null
) => void;
}>;
export const PinMessageDialog = memo(function PinMessageDialog(
props: PinMessageDialogProps
) {
const { i18n, messageId, onPinMessage, onOpenChange } = props;
const { i18n, messageId, onPinnedMessageAdd, onOpenChange } = props;
const [duration, setDuration] = useState(DurationOption.TIME_7_DAYS);
const handleValueChange = useCallback((value: string) => {
@@ -51,14 +51,18 @@ export const PinMessageDialog = memo(function PinMessageDialog(
onOpenChange(false);
}, [onOpenChange]);
const handlePinMessage = useCallback(() => {
const handlePinnedMessageAdd = useCallback(() => {
const durationValue = DURATION_OPTIONS[duration];
onPinMessage(messageId, durationValue);
}, [duration, onPinMessage, messageId]);
onPinnedMessageAdd(messageId, durationValue);
}, [duration, onPinnedMessageAdd, messageId]);
return (
<AxoDialog.Root open={props.open} onOpenChange={onOpenChange}>
<AxoDialog.Content size="sm" escape="cancel-is-noop">
<AxoDialog.Content
size="sm"
escape="cancel-is-noop"
disableMissingAriaDescriptionWarning
>
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:PinMessageDialog__Title')}
@@ -101,7 +105,10 @@ export const PinMessageDialog = memo(function PinMessageDialog(
<AxoDialog.Action variant="secondary" onClick={handleCancel}>
{i18n('icu:PinMessageDialog__Cancel')}
</AxoDialog.Action>
<AxoDialog.Action variant="primary" onClick={handlePinMessage}>
<AxoDialog.Action
variant="primary"
onClick={handlePinnedMessageAdd}
>
{i18n('icu:PinMessageDialog__Pin')}
</AxoDialog.Action>
</AxoDialog.Actions>
+5 -2
View File
@@ -42,8 +42,11 @@ export class JobLogger implements LoggerType {
this.logger.trace(this.#prefix(), ...args);
}
child(): LoggerType {
throw new Error('Not supported');
child(name: string): JobLogger {
return new JobLogger(
{ id: this.#id, queueType: this.#queueType },
this.logger.child(name)
);
}
#prefix(): string {
@@ -20,6 +20,7 @@ import {
handleMultipleSendErrors,
maybeExpandErrors,
} from './handleMultipleSendErrors.std.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
export type SendMessageJobOptions<Data> = Readonly<{
sendName: string; // ex: 'sendExampleMessage'
@@ -49,15 +50,12 @@ export function createSendMessageJob<Data>(
return;
}
const {
allRecipientServiceIds,
recipientServiceIdsWithoutMe,
untrustedServiceIds,
} = getSendRecipientLists({
log,
conversation,
conversationIds: conversation.getRecipients(),
});
const { recipientServiceIdsWithoutMe, untrustedServiceIds } =
getSendRecipientLists({
log,
conversation,
conversationIds: Array.from(conversation.getMemberConversationIds()),
});
if (untrustedServiceIds.length > 0) {
window.reduxActions.conversations.conversationStoppedByMissingVerification(
@@ -84,10 +82,11 @@ export function createSendMessageJob<Data>(
await conversation.queueJob(
`conversationQueue/${sendName}/sync`,
async () => {
const ourAci = itemStorage.user.getCheckedAci();
const encodedDataMessage = await job.messaging.getDataOrEditMessage(
{
...messageOptions,
recipients: allRecipientServiceIds,
recipients: [ourAci],
}
);
+12 -19
View File
@@ -2,9 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { DataWriter } from '../sql/Client.preload.js';
import type { AciString } from '../types/ServiceId.std.js';
import { DurationInSeconds } from '../util/durations/duration-in-seconds.std.js';
import * as RemoteConfig from '../RemoteConfig.dom.js';
import { parseIntWithFallback } from '../util/parseIntWithFallback.std.js';
import type { DurationInSeconds } from '../util/durations/duration-in-seconds.std.js';
import { createLogger } from '../logging/log.std.js';
import type { MessageModifierTarget } from './helpers/findMessageModifierTarget.preload.js';
import { findMessageModifierTarget } from './helpers/findMessageModifierTarget.preload.js';
@@ -12,6 +10,8 @@ import { isValidSenderAciForConversation } from './helpers/isValidSenderAciForCo
import { isGroupV2 } from '../util/whatTypeOfConversation.dom.js';
import { SignalService as Proto } from '../protobuf/index.std.js';
import type { ConversationModel } from '../models/conversations.preload.js';
import { getPinnedMessagesLimit } from '../util/pinnedMessages.dom.js';
import { getPinnedMessageExpiresAt } from '../util/pinnedMessages.std.js';
const { AccessRequired } = Proto.AccessControl;
const { Role } = Proto.Member;
@@ -92,6 +92,10 @@ export async function onPinnedMessageAdd(
log.info(`Truncated older pinned message ${pinnedMessageId}`);
}
}
window.reduxActions.conversations.onPinnedMessagesChanged(
targetConversation.id
);
}
export async function onPinnedMessageRemove(
@@ -120,6 +124,7 @@ export async function onPinnedMessageRemove(
}
const targetMessageId = target.targetMessage.id;
const targetConversationId = target.targetConversation.id;
const deletedPinnedMessageId =
await DataWriter.deletePinnedMessageByMessageId(targetMessageId);
@@ -132,6 +137,10 @@ export async function onPinnedMessageRemove(
log.info(
`Deleted pinned message ${deletedPinnedMessageId} for messageId ${targetMessageId}`
);
window.reduxActions.conversations.onPinnedMessagesChanged(
targetConversationId
);
}
function canSenderEditGroupAttributes(
@@ -175,19 +184,3 @@ function validatePinnedMessageTarget(
return null;
}
function getPinnedMessageExpiresAt(
receivedAtTimestamp: number,
pinDuration: DurationInSeconds | null
): number | null {
if (pinDuration == null) {
return null;
}
const pinDurationMs = DurationInSeconds.toMillis(pinDuration);
return receivedAtTimestamp + pinDurationMs;
}
function getPinnedMessagesLimit(): number {
const remoteValue = RemoteConfig.getValue('global.pinned_message_limit');
return parseIntWithFallback(remoteValue, 3);
}
+12
View File
@@ -274,6 +274,7 @@ const {
getMostRecentAddressableMessages,
getMostRecentAddressableNondisappearingMessages,
getNewerMessagesByConversation,
getPinnedMessagesForConversation,
} = DataReader;
const { addStickerPackReference } = DataWriter;
@@ -1688,10 +1689,13 @@ export class ConversationModel {
`latest timestamp=${cleaned.at(-1)?.sent_at}`
);
const pinnedMessages = await getPinnedMessagesForConversation(this.id);
addPreloadData({
conversationId: this.id,
messages: cleaned,
metrics,
pinnedMessages,
unboundedFetch,
});
} finally {
@@ -1803,6 +1807,9 @@ export class ConversationModel {
`latest timestamp=${cleaned.at(-1)?.sent_at}`
);
const pinnedMessages =
await DataReader.getPinnedMessagesForConversation(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
// metrics, fetched a bit before, that's likely a race condition. So we tell our
@@ -1813,6 +1820,7 @@ export class ConversationModel {
conversationId,
messages: cleaned,
metrics,
pinnedMessages,
scrollToMessageId,
unboundedFetch,
});
@@ -1977,10 +1985,14 @@ export class ConversationModel {
const scrollToMessageId =
options && options.disableScroll ? undefined : messageId;
const pinnedMessages =
await DataReader.getPinnedMessagesForConversation(conversationId);
messagesReset({
conversationId,
messages: cleaned,
metrics,
pinnedMessages,
scrollToMessageId,
});
} catch (error) {
+180 -62
View File
@@ -6,7 +6,7 @@ import lodash from 'lodash';
import { type PhoneNumber } from 'google-libphonenumber';
import { clipboard, ipcRenderer } from 'electron';
import type { ReadonlyDeep } from 'type-fest';
import type { ReadonlyDeep, SetOptional } from 'type-fest';
import { DataReader, DataWriter } from '../../sql/Client.preload.js';
import type { AttachmentType } from '../../types/Attachment.std.js';
import type { StateType as RootStateType } from '../reducer.preload.js';
@@ -96,6 +96,7 @@ import {
getPendingAvatarDownloadSelector,
getAllConversations,
getActivePanel,
getSelectedConversationId,
} from '../selectors/conversations.dom.js';
import { getIntl } from '../selectors/user.std.js';
import type {
@@ -243,6 +244,10 @@ 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 { PinnedMessage } 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';
const {
chunk,
@@ -503,10 +508,22 @@ export type ConversationMessageType = ReadonlyDeep<{
export type ConversationPreloadDataType = ReadonlyDeep<{
conversationId: string;
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
pinnedMessages: ReadonlyArray<PinnedMessage>;
metrics: MessageMetricsType;
unboundedFetch: boolean;
}>;
export type MessagesResetDataType = ReadonlyDeep<
ConversationPreloadDataType & {
scrollToMessageId?: string;
}
>;
export type MessagesResetOptionsType = SetOptional<
MessagesResetDataType,
'unboundedFetch'
>;
export type MessagesByConversationType = ReadonlyDeep<{
[key: string]: ConversationMessageType | undefined;
}>;
@@ -625,6 +642,7 @@ export type ConversationsStateType = ReadonlyDeep<{
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
messagesByConversation: MessagesByConversationType;
pinnedMessages: ReadonlyArray<PinnedMessage>;
lastCenterMessageByConversation: LastCenterMessageByConversationType;
@@ -698,6 +716,7 @@ export const SET_PENDING_REQUESTED_AVATAR_DOWNLOAD =
'conversations/SET_PENDING_REQUESTED_AVATAR_DOWNLOAD';
export const SET_PROFILE_UPDATE_ERROR =
'conversations/SET_PROFILE_UPDATE_ERROR';
const REPLACE_PINNED_MESSAGES = 'conversations/REPLACE_PINNED_MESSAGES';
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
@@ -934,15 +953,7 @@ export type RepairOldestMessageActionType = ReadonlyDeep<{
}>;
export type MessagesResetActionType = ReadonlyDeep<{
type: 'MESSAGES_RESET';
payload: {
conversationId: string;
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
metrics: MessageMetricsType;
scrollToMessageId?: string;
// The set of provided messages should be trusted, even if it conflicts with metrics,
// because we weren't looking for a specific time window of messages with our query.
unboundedFetch: boolean;
};
payload: MessagesResetDataType;
}>;
export type SetMessageLoadingStateActionType = ReadonlyDeep<{
type: 'SET_MESSAGE_LOADING_STATE';
@@ -1086,6 +1097,13 @@ type ReplaceAvatarsActionType = ReadonlyDeep<{
avatars: ReadonlyArray<AvatarDataType>;
};
}>;
type ReplacePinnedMessagesActionType = ReadonlyDeep<{
type: typeof REPLACE_PINNED_MESSAGES;
payload: {
conversationId: string;
pinnedMessages: ReadonlyArray<PinnedMessage>;
};
}>;
export type AddPreloadDataActionType = ReadonlyDeep<{
type: 'ADD_PRELOAD_DATA';
payload: ConversationPreloadDataType;
@@ -1141,6 +1159,7 @@ export type ConversationActionType =
| RepairNewestMessageActionType
| RepairOldestMessageActionType
| ReplaceAvatarsActionType
| ReplacePinnedMessagesActionType
| ReviewConversationNameCollisionActionType
| ScrollToMessageActionType
| SetPendingRequestedAvatarDownloadActionType
@@ -1236,6 +1255,9 @@ export const actions = {
onArchive,
onMarkUnread,
onMoveToInbox,
onPinnedMessagesChanged,
onPinnedMessageAdd,
onPinnedMessageRemove,
onUndoArchive,
openGiftBadge,
popPanelForConversation,
@@ -1329,7 +1351,7 @@ function onArchive(
unknown,
ConversationUnloadedActionType | ShowToastActionType
> {
return (dispatch, getState) => {
return dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('onArchive: Conversation not found!');
@@ -1338,11 +1360,7 @@ function onArchive(
const wasPinned = conversation.attributes.isPinned ?? false;
conversation.setArchived(true);
onConversationClosed(conversationId, 'archive')(
dispatch,
getState,
undefined
);
dispatch(onConversationClosed(conversationId, 'archive'));
dispatch({
type: SHOW_TOAST,
@@ -1365,7 +1383,7 @@ function onUndoArchive(
unknown,
TargetedConversationChangedActionType
> {
return (dispatch, getState) => {
return dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('onUndoArchive: Conversation not found!');
@@ -1375,9 +1393,11 @@ function onUndoArchive(
if (options.wasPinned) {
conversation.pin();
}
showConversation({
conversationId,
})(dispatch, getState, null);
dispatch(
showConversation({
conversationId,
})
);
};
}
@@ -1915,7 +1935,7 @@ function deleteMessages({
messageIds: ReadonlyArray<string>;
lastSelectedMessage?: MessageTimestamps;
}): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async (dispatch, getState) => {
return async dispatch => {
if (!messageIds || messageIds.length === 0) {
log.warn('deleteMessages: No message ids provided');
return;
@@ -1968,7 +1988,7 @@ function deleteMessages({
cleanupMessages,
});
popPanelForConversation()(dispatch, getState, undefined);
dispatch(popPanelForConversation());
if (nearbyMessageId != null) {
dispatch(scrollToMessage(conversationId, nearbyMessageId));
@@ -2019,11 +2039,7 @@ function destroyMessages(
name: 'destroymessages',
idForLogging: conversation.idForLogging(),
task: async () => {
onConversationClosed(conversationId, 'delete messages')(
dispatch,
getState,
undefined
);
dispatch(onConversationClosed(conversationId, 'delete messages'));
await conversation.destroyMessages({ source: 'local-delete' });
@@ -2110,11 +2126,7 @@ function setMessageToEdit(
dispatch(scrollToMessage(conversationId, messageId));
}
setQuoteByMessageId(conversationId, undefined)(
dispatch,
getState,
undefined
);
dispatch(setQuoteByMessageId(conversationId, undefined));
let attachmentThumbnail: string | undefined;
if (message.attachments) {
@@ -3379,21 +3391,14 @@ function reviewConversationNameCollision(): ReviewConversationNameCollisionActio
};
}
export type MessageResetOptionsType = ReadonlyDeep<{
conversationId: string;
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
metrics: MessageMetricsType;
scrollToMessageId?: string;
unboundedFetch?: boolean;
}>;
function messagesReset({
conversationId,
messages,
metrics,
pinnedMessages,
scrollToMessageId,
unboundedFetch,
}: MessageResetOptionsType): MessagesResetActionType {
}: MessagesResetOptionsType): MessagesResetActionType {
for (const message of messages) {
strictAssert(
message.conversationId === conversationId,
@@ -3405,10 +3410,11 @@ function messagesReset({
return {
type: 'MESSAGES_RESET',
payload: {
unboundedFetch: Boolean(unboundedFetch),
unboundedFetch: unboundedFetch ?? false,
conversationId,
messages,
metrics,
pinnedMessages,
scrollToMessageId,
},
};
@@ -4336,7 +4342,7 @@ export function saveAttachmentFromMessage(
messageId: string,
providedAttachment?: AttachmentType
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async (dispatch, getState) => {
return async dispatch => {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(
@@ -4354,7 +4360,7 @@ export function saveAttachmentFromMessage(
? providedAttachment
: attachments[0];
saveAttachment(attachment, timestamp)(dispatch, getState, null);
dispatch(saveAttachment(attachment, timestamp));
};
}
@@ -4840,7 +4846,7 @@ function showConversation({
// notify composer in case we need to stop recording a voice note
if (conversations.selectedConversationId) {
saveDraftRecordingIfNeeded()(dispatch, getState, undefined);
dispatch(saveDraftRecordingIfNeeded());
dispatch(
onConversationClosed(
conversations.selectedConversationId,
@@ -4874,7 +4880,7 @@ function onConversationOpened(
| SetFocusActionType
| SetQuotedMessageActionType
> {
return async (dispatch, getState) => {
return async dispatch => {
const promises: Array<Promise<void>> = [];
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
@@ -4926,11 +4932,7 @@ function onConversationOpened(
const quotedMessageId = conversation.get('quotedMessageId');
if (quotedMessageId) {
setQuoteByMessageId(conversation.id, quotedMessageId)(
dispatch,
getState,
undefined
);
dispatch(setQuoteByMessageId(conversation.id, quotedMessageId));
}
promises.push(conversation.fetchLatestGroupV2Data());
@@ -4961,10 +4963,12 @@ function onConversationOpened(
promises.push(conversation.updateVerified());
replaceAttachments(
conversation.get('id'),
conversation.get('draftAttachments') || []
)(dispatch, getState, undefined);
dispatch(
replaceAttachments(
conversation.get('id'),
conversation.get('draftAttachments') || []
)
);
dispatch(resetComposer(conversationId));
await Promise.all(promises);
@@ -5113,6 +5117,107 @@ 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<ReplacePinnedMessagesActionType> {
return async (dispatch, getState) => {
const selectedConversationId = getSelectedConversationId(getState());
if (
selectedConversationId == null ||
selectedConversationId !== conversationId
) {
return;
}
const pinnedMessages =
await DataReader.getPinnedMessagesForConversation(conversationId);
dispatch({
type: REPLACE_PINNED_MESSAGES,
payload: {
conversationId,
pinnedMessages,
},
});
};
}
function onPinnedMessageAdd(
targetMessageId: string,
pinDurationSeconds: DurationInSeconds | null
): StateThunk {
return async dispatch => {
const target = await getPinnedMessageTarget(targetMessageId);
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,
});
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);
dispatch(onPinnedMessagesChanged(target.conversationId));
};
}
// Reducer
export function getEmptyState(): ConversationsStateType {
@@ -5126,6 +5231,7 @@ export function getEmptyState(): ConversationsStateType {
lastCenterMessageByConversation: {},
messagesByConversation: {},
messagesLookup: {},
pinnedMessages: [],
targetedMessage: undefined,
targetedMessageCounter: 0,
targetedMessageSource: undefined,
@@ -5532,15 +5638,10 @@ function updateMessageLookup(
conversationId,
messages,
metrics,
pinnedMessages,
scrollToMessageId,
unboundedFetch,
}: {
conversationId: string;
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
metrics: MessageMetricsType;
scrollToMessageId?: string | undefined;
unboundedFetch: boolean;
}
}: MessagesResetDataType
): ConversationsStateType {
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
@@ -5580,6 +5681,7 @@ function updateMessageLookup(
targetedMessage: scrollToMessageId,
targetedMessageCounter: state.targetedMessageCounter + 1,
targetedMessageSource: TargetedMessageSource.Reset,
pinnedMessages,
}
: {}),
messagesLookup: {
@@ -5897,6 +5999,7 @@ export function reducer(
messagesByConversation: omit(state.messagesByConversation, [
conversationId,
]),
pinnedMessages: [],
};
}
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
@@ -7490,6 +7593,21 @@ export function reducer(
};
}
if (action.type === REPLACE_PINNED_MESSAGES) {
// Discard new pinned messages if the `selectedConversationId` has changed.
if (
state.selectedConversationId == null ||
action.payload.conversationId !== state.selectedConversationId
) {
return state;
}
return {
...state,
pinnedMessages: action.payload.pinnedMessages,
};
}
if (
action.type === CHANGE_LOCATION &&
action.payload.selectedLocation.tab === NavTab.Chats
+8
View File
@@ -231,6 +231,14 @@ export const getTargetedMessageSource = createSelector(
return state.targetedMessageSource;
}
);
export const getPinnedMessageIds = createSelector(
getConversations,
(state: ConversationsStateType): ReadonlyArray<string> | null => {
return state.pinnedMessages.map(pinnedMessage => {
return pinnedMessage.messageId;
});
}
);
export const getSelectedMessageIds = createSelector(
getConversations,
(state: ConversationsStateType): ReadonlyArray<string> | undefined => {
+18
View File
@@ -103,6 +103,7 @@ import {
getMessages,
getCachedConversationMemberColorsSelector,
getContactNameColor,
getPinnedMessageIds,
} from './conversations.dom.js';
import {
getIntl,
@@ -167,6 +168,7 @@ 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';
import { canEditGroupInfo } from '../../util/canEditGroupInfo.preload.js';
const { groupBy, isEmpty, isNumber, isObject, map } = lodash;
@@ -204,6 +206,7 @@ export type GetPropsForBubbleOptions = Readonly<{
ourPni: PniString | undefined;
targetedMessageId?: string;
targetedMessageCounter?: number;
pinnedMessageIds: ReadonlyArray<string> | null;
selectedMessageIds: ReadonlyArray<string> | undefined;
regionCode?: string;
callSelector: CallSelectorType;
@@ -775,6 +778,7 @@ export type GetPropsForMessageOptions = Pick<
| 'ourNumber'
| 'targetedMessageId'
| 'targetedMessageCounter'
| 'pinnedMessageIds'
| 'selectedMessageIds'
| 'regionCode'
| 'accountSelector'
@@ -877,6 +881,7 @@ export const getPropsForMessage = (
regionCode,
targetedMessageId,
targetedMessageCounter,
pinnedMessageIds,
selectedMessageIds,
contactNameColors,
defaultConversationColor,
@@ -895,6 +900,7 @@ export const getPropsForMessage = (
const activeCallConversationId = activeCall?.conversationId;
const isTargeted = message.id === targetedMessageId;
const isPinned = pinnedMessageIds?.includes(message.id) ?? false;
const isSelected = selectedMessageIds?.includes(message.id) ?? false;
const isSelectMode = selectedMessageIds != null;
@@ -938,6 +944,7 @@ export const getPropsForMessage = (
canDownload: canDownload(message, conversationSelector),
canEndPoll: canEndPoll(message),
canForward: canForward(message),
canPinMessages: canPinMessages(conversation),
canReact: canReact(message, ourConversationId, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector),
canRetry: hasErrors(message),
@@ -966,6 +973,7 @@ export const getPropsForMessage = (
isBlocked: conversation.isBlocked || false,
isEditedMessage: Boolean(message.editHistory),
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
isPinned,
isSelected,
isSelectMode,
isSMS: message.sms === true,
@@ -1003,6 +1011,7 @@ export const getMessagePropsSelector = createSelector(
getAccountSelector,
getCachedConversationMemberColorsSelector,
getTargetedMessage,
getPinnedMessageIds,
getSelectedMessageIds,
getDefaultConversationColor,
(
@@ -1015,6 +1024,7 @@ export const getMessagePropsSelector = createSelector(
accountSelector,
cachedConversationMemberColorsSelector,
targetedMessage,
pinnedMessageIds,
selectedMessageIds,
defaultConversationColor
) =>
@@ -1033,6 +1043,7 @@ export const getMessagePropsSelector = createSelector(
regionCode,
targetedMessageCounter: targetedMessage?.counter,
targetedMessageId: targetedMessage?.id,
pinnedMessageIds,
selectedMessageIds,
defaultConversationColor,
});
@@ -2391,6 +2402,10 @@ export function canForward(message: ReadonlyMessageAttributesType): boolean {
);
}
export function canPinMessages(conversation: ConversationType): boolean {
return canEditGroupInfo(conversation);
}
export function getLastChallengeError(
message: Pick<MessageWithUIFieldsType, 'errors'>
): ShallowChallengeError | undefined {
@@ -2430,6 +2445,7 @@ export const getMessageDetails = createSelector(
getUserPNI,
getUserConversationId,
getUserNumber,
getPinnedMessageIds,
getSelectedMessageIds,
getDefaultConversationColor,
getHasUnidentifiedDeliveryIndicators,
@@ -2444,6 +2460,7 @@ export const getMessageDetails = createSelector(
ourPni,
ourConversationId,
ourNumber,
pinnedMessageIds,
selectedMessageIds,
defaultConversationColor,
hasUnidentifiedDeliveryIndicators
@@ -2578,6 +2595,7 @@ export const getMessageDetails = createSelector(
ourConversationId,
ourNumber,
regionCode,
pinnedMessageIds,
selectedMessageIds,
defaultConversationColor,
}),
+3
View File
@@ -11,6 +11,7 @@ import {
getSelectedMessageIds,
getMessages,
getCachedConversationMemberColorsSelector,
getPinnedMessageIds,
} from './conversations.dom.js';
import { getAccountSelector } from './accounts.std.js';
import {
@@ -53,6 +54,7 @@ const getTimelineItem = (
const callHistorySelector = getCallHistorySelector(state);
const activeCall = getActiveCall(state);
const accountSelector = getAccountSelector(state);
const pinnedMessageIds = getPinnedMessageIds(state);
const selectedMessageIds = getSelectedMessageIds(state);
const defaultConversationColor = getDefaultConversationColor(state);
@@ -70,6 +72,7 @@ const getTimelineItem = (
callHistorySelector,
activeCall,
accountSelector,
pinnedMessageIds,
selectedMessageIds,
defaultConversationColor,
});
+4
View File
@@ -131,6 +131,8 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
messageExpanded,
onPinnedMessageAdd,
onPinnedMessageRemove,
openGiftBadge,
pushPanelForConversation,
retryDeleteForEveryone,
@@ -237,6 +239,8 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
onPinnedMessageAdd={onPinnedMessageAdd}
onPinnedMessageRemove={onPinnedMessageRemove}
scrollToPinnedMessage={scrollToPinnedMessage}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
@@ -7,10 +7,13 @@ import { VoiceNotesPlaybackContext } from '../../components/VoiceNotesPlaybackCo
import type { Props as MessageAudioProps } from './MessageAudio.preload.js';
import { SmartMessageAudio } from './MessageAudio.preload.js';
type AudioAttachmentProps = Omit<MessageAudioProps, 'computePeaks'>;
export type RenderAudioAttachmentProps = Omit<
MessageAudioProps,
'computePeaks'
>;
export function renderAudioAttachment(
props: AudioAttachmentProps
props: RenderAudioAttachmentProps
): ReactElement {
return (
<VoiceNotesPlaybackContext.Consumer>
+23 -6
View File
@@ -5,9 +5,24 @@ import type { ConversationAttributesType } from '../model-types.d.ts';
import { SignalService as Proto } from '../protobuf/index.std.js';
import { isGroupV2 } from './whatTypeOfConversation.dom.js';
import { areWeAdmin } from './areWeAdmin.preload.js';
import type { ConversationType } from '../state/ducks/conversations.preload.js';
function getConversationAccessControlAttributes(
conversation: ConversationAttributesType | ConversationType
): number | null {
if ('accessControl' in conversation) {
return conversation.accessControl?.attributes ?? null;
}
if ('accessControlAttributes' in conversation) {
return conversation.accessControlAttributes ?? null;
}
return null;
}
export function canEditGroupInfo(
conversationAttrs: ConversationAttributesType
conversationAttrs: ConversationAttributesType | ConversationType
): boolean {
if (!isGroupV2(conversationAttrs)) {
return false;
@@ -17,9 +32,11 @@ export function canEditGroupInfo(
return false;
}
return (
areWeAdmin(conversationAttrs) ||
conversationAttrs.accessControl?.attributes ===
Proto.AccessControl.AccessRequired.MEMBER
);
if (areWeAdmin(conversationAttrs)) {
return true;
}
const accessControlAttributes =
getConversationAccessControlAttributes(conversationAttrs);
return accessControlAttributes === Proto.AccessControl.AccessRequired.MEMBER;
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as RemoteConfig from '../RemoteConfig.dom.js';
import { parseIntWithFallback } from './parseIntWithFallback.std.js';
export function getPinnedMessagesLimit(): number {
const remoteValue = RemoteConfig.getValue('global.pinned_message_limit');
return parseIntWithFallback(remoteValue, 3);
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DurationInSeconds } from './durations/duration-in-seconds.std.js';
export function getPinnedMessageExpiresAt(
receivedAtTimestamp: number,
pinDuration: DurationInSeconds | null
): number | null {
if (pinDuration == null) {
return null;
}
const pinDurationMs = DurationInSeconds.toMillis(pinDuration);
return receivedAtTimestamp + pinDurationMs;
}