mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-08 08:58:38 +01:00
Setup pin/unpin actions and mark messages pinned in timeline
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user