mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
Integrate pinned messages bar/panel
This commit is contained in:
@@ -462,6 +462,10 @@ const renderMiniPlayer = () => (
|
||||
<div>If active, this is where smart mini player would be</div>
|
||||
);
|
||||
|
||||
const renderPinnedMessagesBar = () => (
|
||||
<div>If active, this is where the smart pinned messages bar would be</div>
|
||||
);
|
||||
|
||||
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
discardMessages: action('discardMessages'),
|
||||
getPreferredBadge: () => undefined,
|
||||
@@ -482,6 +486,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
scrollToIndex: overrideProps.scrollToIndex ?? null,
|
||||
scrollToIndexCounter: 0,
|
||||
shouldShowMiniPlayer: Boolean(overrideProps.shouldShowMiniPlayer),
|
||||
shouldShowPinnedMessagesBar: false,
|
||||
totalUnseen: overrideProps.totalUnseen ?? 0,
|
||||
oldestUnseenIndex: overrideProps.oldestUnseenIndex ?? 0,
|
||||
invitedContactsForNewlyCreatedGroup:
|
||||
@@ -494,6 +499,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
renderItem,
|
||||
renderHeroRow,
|
||||
renderMiniPlayer,
|
||||
renderPinnedMessagesBar,
|
||||
renderTypingBubble,
|
||||
renderCollidingAvatars,
|
||||
renderContactSpoofingReviewDialog,
|
||||
|
||||
@@ -103,6 +103,7 @@ type PropsHousekeepingType = {
|
||||
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
|
||||
selectedMessageId?: string;
|
||||
shouldShowMiniPlayer: boolean;
|
||||
shouldShowPinnedMessagesBar: boolean;
|
||||
|
||||
warning?: WarningType;
|
||||
hasContactSpoofingReview: boolean | undefined;
|
||||
@@ -143,6 +144,7 @@ type PropsHousekeepingType = {
|
||||
unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
||||
}) => JSX.Element;
|
||||
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
||||
renderPinnedMessagesBar: () => JSX.Element;
|
||||
renderTypingBubble: (id: string) => JSX.Element;
|
||||
};
|
||||
|
||||
@@ -936,10 +938,12 @@ export class Timeline extends React.Component<
|
||||
renderHeroRow,
|
||||
renderItem,
|
||||
renderMiniPlayer,
|
||||
renderPinnedMessagesBar,
|
||||
renderTypingBubble,
|
||||
reviewConversationNameCollision,
|
||||
scrollToOldestUnreadMention,
|
||||
shouldShowMiniPlayer,
|
||||
shouldShowPinnedMessagesBar,
|
||||
theme,
|
||||
totalUnseen,
|
||||
unreadCount,
|
||||
@@ -1086,7 +1090,7 @@ export class Timeline extends React.Component<
|
||||
|
||||
const warning = Timeline.getWarning(this.props, this.state);
|
||||
let headerElements: ReactNode;
|
||||
if (warning || shouldShowMiniPlayer) {
|
||||
if (warning || shouldShowMiniPlayer || shouldShowPinnedMessagesBar) {
|
||||
let text: ReactChild | undefined;
|
||||
let icon: ReactChild | undefined;
|
||||
let onClose: () => void;
|
||||
@@ -1180,7 +1184,10 @@ export class Timeline extends React.Component<
|
||||
>
|
||||
{measureRef => (
|
||||
<TimelineWarnings ref={measureRef}>
|
||||
{renderMiniPlayer({ shouldFlow: true })}
|
||||
{shouldShowMiniPlayer && renderMiniPlayer({ shouldFlow: true })}
|
||||
{!shouldShowMiniPlayer &&
|
||||
shouldShowPinnedMessagesBar &&
|
||||
renderPinnedMessagesBar()}
|
||||
{text && (
|
||||
<TimelineWarning i18n={i18n} onClose={onClose}>
|
||||
{icon}
|
||||
|
||||
@@ -63,9 +63,6 @@ import {
|
||||
getTooltipContent,
|
||||
} from '../InAnotherCallTooltip.dom.js';
|
||||
import { BadgeSustainerInstructionsDialog } from '../../BadgeSustainerInstructionsDialog.dom.js';
|
||||
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
|
||||
import { isInternalFeaturesEnabled } from '../../../util/isInternalFeaturesEnabled.dom.js';
|
||||
import { tw } from '../../../axo/tw.dom.js';
|
||||
|
||||
enum ModalState {
|
||||
AddingGroupMembers,
|
||||
@@ -728,23 +725,6 @@ export function ConversationDetails({
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
{isInternalFeaturesEnabled() && (
|
||||
<PanelSection title="Internal">
|
||||
<PanelRow
|
||||
onClick={() =>
|
||||
pushPanelForConversation({
|
||||
type: PanelType.PinnedMessages,
|
||||
})
|
||||
}
|
||||
icon={
|
||||
<div className={tw('flex size-8 items-center justify-center')}>
|
||||
<AxoSymbol.Icon symbol="pin" size={20} label={null} />
|
||||
</div>
|
||||
}
|
||||
label="View all pinned messages"
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
{isGroup && (
|
||||
<ConversationDetailsMembershipList
|
||||
canAddNewMembers={canAddNewMembers}
|
||||
|
||||
@@ -64,7 +64,7 @@ const PIN_3: Pin = {
|
||||
bodyRanges: [],
|
||||
},
|
||||
attachment: {
|
||||
type: 'photo',
|
||||
type: 'image',
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
},
|
||||
},
|
||||
@@ -133,15 +133,15 @@ export function Variants(): JSX.Element {
|
||||
message={{ text: { body: SHORT_TEXT, bodyRanges: [] } }}
|
||||
/>
|
||||
<Variant
|
||||
title="Photo attachment with text"
|
||||
title="Image attachment with text"
|
||||
message={{
|
||||
text: { body: SHORT_TEXT, bodyRanges: [] },
|
||||
attachment: { type: 'photo', url: IMAGE_URL },
|
||||
attachment: { type: 'image', url: IMAGE_URL },
|
||||
}}
|
||||
/>
|
||||
<Variant
|
||||
title="Photo attachment"
|
||||
message={{ attachment: { type: 'photo', url: IMAGE_URL } }}
|
||||
title="Image attachment"
|
||||
message={{ attachment: { type: 'image', url: IMAGE_URL } }}
|
||||
/>
|
||||
<Variant
|
||||
title="Video attachment with text"
|
||||
@@ -158,10 +158,7 @@ export function Variants(): JSX.Element {
|
||||
title="Voice message"
|
||||
message={{ attachment: { type: 'voiceMessage' } }}
|
||||
/>
|
||||
<Variant
|
||||
title="GIF message"
|
||||
message={{ attachment: { type: 'gif', url: IMAGE_URL } }}
|
||||
/>
|
||||
<Variant title="GIF message" message={{ attachment: { type: 'gif' } }} />
|
||||
<Variant
|
||||
title="File"
|
||||
message={{ attachment: { type: 'file', name: 'project.zip' } }}
|
||||
@@ -172,10 +169,6 @@ export function Variants(): JSX.Element {
|
||||
/>
|
||||
<Variant title="Sticker" message={{ sticker: true }} />
|
||||
<Variant title="Contact" message={{ contact: { name: 'Tyler' } }} />
|
||||
<Variant
|
||||
title="Address"
|
||||
message={{ contact: { address: '742 Evergreen Terrace' } }}
|
||||
/>
|
||||
<Variant title="Payment" message={{ payment: true }} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import type { ForwardedRef, ReactNode } from 'react';
|
||||
import React, { forwardRef, memo, useCallback, useMemo } from 'react';
|
||||
import { Tabs } from 'radix-ui';
|
||||
import type { LocalizerType } from '../../../types/I18N.std.js';
|
||||
import { tw } from '../../../axo/tw.dom.js';
|
||||
@@ -24,34 +24,41 @@ export type PinMessageText = Readonly<{
|
||||
bodyRanges: HydratedBodyRangesType;
|
||||
}>;
|
||||
|
||||
export type PinMessageAttachment = Readonly<{
|
||||
type: 'photo' | 'video' | 'voiceMessage' | 'gif' | 'file';
|
||||
name?: string;
|
||||
url?: string;
|
||||
export type PinMessageAttachment = Readonly<
|
||||
| { type: 'image'; url: string | null }
|
||||
| { type: 'video'; url: string | null }
|
||||
| { type: 'voiceMessage' }
|
||||
| { type: 'gif' }
|
||||
| { type: 'file'; name: string | null }
|
||||
>;
|
||||
|
||||
export type PinMessageContact = Readonly<{
|
||||
name: string | null;
|
||||
}>;
|
||||
|
||||
export type PinMessagePoll = Readonly<{
|
||||
question: string;
|
||||
}>;
|
||||
|
||||
export type PinMessage = Readonly<{
|
||||
id: string;
|
||||
text?: PinMessageText;
|
||||
attachment?: PinMessageAttachment;
|
||||
contact?: {
|
||||
name?: string;
|
||||
address?: string;
|
||||
};
|
||||
payment?: true;
|
||||
poll?: {
|
||||
question: string;
|
||||
};
|
||||
sticker?: true;
|
||||
text?: PinMessageText | null;
|
||||
attachment?: PinMessageAttachment | null;
|
||||
contact?: PinMessageContact | null;
|
||||
payment?: boolean;
|
||||
poll?: PinMessagePoll | null;
|
||||
sticker?: boolean;
|
||||
}>;
|
||||
|
||||
export type PinSender = Readonly<{
|
||||
id: string;
|
||||
title: string;
|
||||
isMe: boolean;
|
||||
}>;
|
||||
|
||||
export type Pin = Readonly<{
|
||||
id: PinnedMessageId;
|
||||
sender: {
|
||||
id: string;
|
||||
title: string;
|
||||
isMe: boolean;
|
||||
};
|
||||
sender: PinSender;
|
||||
message: PinMessage;
|
||||
}>;
|
||||
|
||||
@@ -60,8 +67,8 @@ export type PinnedMessagesBarProps = Readonly<{
|
||||
pins: ReadonlyArray<Pin>;
|
||||
current: PinnedMessageId;
|
||||
onCurrentChange: (current: PinnedMessageId) => void;
|
||||
onPinGoTo: (pinnedMessageId: PinnedMessageId) => void;
|
||||
onPinRemove: (pinnedMessageId: PinnedMessageId) => void;
|
||||
onPinGoTo: (messageId: string) => void;
|
||||
onPinRemove: (messageId: string) => void;
|
||||
onPinsShowAll: () => void;
|
||||
}>;
|
||||
|
||||
@@ -222,22 +229,32 @@ function TabTrigger(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function Content(props: {
|
||||
i18n: LocalizerType;
|
||||
pin: Pin;
|
||||
onPinGoTo: (pinnedMessageId: PinnedMessageId) => void;
|
||||
onPinRemove: (pinnedMessageId: PinnedMessageId) => void;
|
||||
onPinsShowAll: () => void;
|
||||
}) {
|
||||
const { i18n, pin, onPinGoTo, onPinRemove, onPinsShowAll } = props;
|
||||
const Content = forwardRef(function Content(
|
||||
props: {
|
||||
i18n: LocalizerType;
|
||||
pin: Pin;
|
||||
onPinGoTo: (messageId: string) => void;
|
||||
onPinRemove: (messageId: string) => void;
|
||||
onPinsShowAll: () => void;
|
||||
},
|
||||
ref: ForwardedRef<HTMLDivElement>
|
||||
): JSX.Element {
|
||||
const {
|
||||
i18n,
|
||||
pin,
|
||||
onPinGoTo,
|
||||
onPinRemove,
|
||||
onPinsShowAll,
|
||||
...forwardedProps
|
||||
} = props;
|
||||
|
||||
const handlePinGoTo = useCallback(() => {
|
||||
onPinGoTo(pin.id);
|
||||
}, [onPinGoTo, pin.id]);
|
||||
onPinGoTo(pin.message.id);
|
||||
}, [onPinGoTo, pin.message.id]);
|
||||
|
||||
const handlePinRemove = useCallback(() => {
|
||||
onPinRemove(pin.id);
|
||||
}, [onPinRemove, pin.id]);
|
||||
onPinRemove(pin.message.id);
|
||||
}, [onPinRemove, pin.message.id]);
|
||||
|
||||
const handlePinsShowAll = useCallback(() => {
|
||||
onPinsShowAll();
|
||||
@@ -248,7 +265,11 @@ function Content(props: {
|
||||
}, [pin.message]);
|
||||
|
||||
return (
|
||||
<div className={tw('flex min-w-0 flex-1 flex-row items-center')}>
|
||||
<div
|
||||
ref={ref}
|
||||
{...forwardedProps}
|
||||
className={tw('flex min-w-0 flex-1 flex-row items-center')}
|
||||
>
|
||||
{thumbnailUrl != null && <ImageThumbnail url={thumbnailUrl} />}
|
||||
<div className={tw('min-w-0 flex-1')}>
|
||||
<h1 className={tw('type-body-small font-semibold text-label-primary')}>
|
||||
@@ -297,14 +318,14 @@ function Content(props: {
|
||||
</AriaClickable.SubWidget>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function getThumbnailUrl(message: PinMessage): string | null {
|
||||
if (message.attachment == null) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
message.attachment.type === 'photo' ||
|
||||
message.attachment.type === 'image' ||
|
||||
message.attachment.type === 'video'
|
||||
) {
|
||||
return message.attachment.url ?? null;
|
||||
@@ -353,23 +374,13 @@ function getMessagePreviewIcon(
|
||||
};
|
||||
}
|
||||
}
|
||||
if (message.contact?.name != null) {
|
||||
if (message.contact != null) {
|
||||
return {
|
||||
symbol: 'person-circle',
|
||||
label: i18n(
|
||||
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Contact'
|
||||
),
|
||||
label: message.contact.name ?? i18n('icu:unknownContact'),
|
||||
};
|
||||
}
|
||||
if (message.contact?.address != null) {
|
||||
return {
|
||||
symbol: 'location',
|
||||
label: i18n(
|
||||
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Address'
|
||||
),
|
||||
};
|
||||
}
|
||||
if (message.payment != null) {
|
||||
if (message.payment) {
|
||||
return {
|
||||
symbol: 'creditcard',
|
||||
label: i18n(
|
||||
@@ -383,7 +394,7 @@ function getMessagePreviewIcon(
|
||||
label: i18n('icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Poll'),
|
||||
};
|
||||
}
|
||||
if (message.sticker != null) {
|
||||
if (message.sticker) {
|
||||
return {
|
||||
symbol: 'sticker',
|
||||
label: i18n(
|
||||
@@ -402,7 +413,7 @@ function getMessagePreviewText(
|
||||
return <MessageTextPreview i18n={i18n} text={message.text} />;
|
||||
}
|
||||
if (message.attachment != null) {
|
||||
if (message.attachment.type === 'photo') {
|
||||
if (message.attachment.type === 'image') {
|
||||
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Photo');
|
||||
}
|
||||
if (message.attachment.type === 'video') {
|
||||
@@ -417,14 +428,11 @@ function getMessagePreviewText(
|
||||
if (message.attachment.type === 'file') {
|
||||
return <UserText text={message.attachment.name ?? ''} />;
|
||||
}
|
||||
throw missingCaseError(message.attachment.type);
|
||||
throw missingCaseError(message.attachment);
|
||||
}
|
||||
if (message.contact?.name != null) {
|
||||
return <UserText text={message.contact.name} />;
|
||||
}
|
||||
if (message.contact?.address != null) {
|
||||
return <UserText text={message.contact.address} />;
|
||||
}
|
||||
if (message.payment != null) {
|
||||
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Payment');
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import React, {
|
||||
import { useLayoutEffect } from '@react-aria/utils';
|
||||
import type { LocalizerType } from '../../../types/I18N.std.js';
|
||||
import type { ConversationType } from '../../../state/ducks/conversations.preload.js';
|
||||
import type { PinnedMessage } from '../../../types/PinnedMessage.std.js';
|
||||
import type { PinnedMessageRenderData } from '../../../types/PinnedMessage.std.js';
|
||||
import type { SmartTimelineItemProps } from '../../../state/smart/TimelineItem.preload.js';
|
||||
import { WidthBreakpoint } from '../../_util.std.js';
|
||||
import { AxoScrollArea } from '../../../axo/AxoScrollArea.dom.js';
|
||||
@@ -30,7 +30,7 @@ import { AxoButton } from '../../../axo/AxoButton.dom.js';
|
||||
export type PinnedMessagesPanelProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
conversation: ConversationType;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessage>;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
|
||||
renderTimelineItem: (props: SmartTimelineItemProps) => JSX.Element;
|
||||
}>;
|
||||
|
||||
@@ -60,7 +60,7 @@ export const PinnedMessagesPanel = memo(function PinnedMessagesPanel(
|
||||
const next = props.pinnedMessages[pinnedMessageIndex + 1];
|
||||
const prev = props.pinnedMessages[pinnedMessageIndex - 1];
|
||||
return (
|
||||
<Fragment key={pinnedMessage.id}>
|
||||
<Fragment key={pinnedMessage.pinnedMessage.id}>
|
||||
{props.renderTimelineItem({
|
||||
containerElementRef,
|
||||
containerWidthBreakpoint,
|
||||
@@ -69,9 +69,9 @@ export const PinnedMessagesPanel = memo(function PinnedMessagesPanel(
|
||||
isBlocked: props.conversation.isBlocked ?? false,
|
||||
isGroup: props.conversation.type === 'group',
|
||||
isOldestTimelineItem: pinnedMessageIndex === 0,
|
||||
messageId: pinnedMessage.messageId,
|
||||
nextMessageId: next?.messageId,
|
||||
previousMessageId: prev?.messageId,
|
||||
messageId: pinnedMessage.message.id,
|
||||
nextMessageId: next?.message.id,
|
||||
previousMessageId: prev?.message.id,
|
||||
unreadIndicatorPlacement: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function onPinnedMessageAdd(
|
||||
}
|
||||
}
|
||||
|
||||
window.reduxActions.conversations.onPinnedMessagesChanged(
|
||||
window.reduxActions.pinnedMessages.onPinnedMessagesChanged(
|
||||
targetConversation.id
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export async function onPinnedMessageRemove(
|
||||
`Deleted pinned message ${deletedPinnedMessageId} for messageId ${targetMessageId}`
|
||||
);
|
||||
|
||||
window.reduxActions.conversations.onPinnedMessagesChanged(
|
||||
window.reduxActions.pinnedMessages.onPinnedMessagesChanged(
|
||||
targetConversationId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,12 +69,14 @@ import type {
|
||||
PinnedMessage,
|
||||
PinnedMessageId,
|
||||
PinnedMessageParams,
|
||||
PinnedMessageRenderData,
|
||||
} from '../types/PinnedMessage.std.js';
|
||||
import type { AppendPinnedMessageResult } from './server/pinnedMessages.std.js';
|
||||
import type {
|
||||
RemoteMegaphoneId,
|
||||
RemoteMegaphoneType,
|
||||
} from '../types/Megaphone.std.js';
|
||||
import { QueryFragment, sqlJoin } from './util.std.js';
|
||||
|
||||
export type ReadableDB = Database & { __readable_db: never };
|
||||
export type WritableDB = ReadableDB & { __writable_db: never };
|
||||
@@ -191,6 +193,12 @@ export const MESSAGE_COLUMNS = [
|
||||
...MESSAGE_NON_PRIMARY_KEY_COLUMNS,
|
||||
] as const;
|
||||
|
||||
export const MESSAGE_COLUMNS_FRAGMENTS = MESSAGE_COLUMNS.map(
|
||||
column => new QueryFragment(column, [])
|
||||
);
|
||||
|
||||
export const MESSAGE_COLUMNS_SELECT = sqlJoin(MESSAGE_COLUMNS_FRAGMENTS);
|
||||
|
||||
export type MessageTypeUnhydrated = {
|
||||
json: string;
|
||||
|
||||
@@ -980,7 +988,7 @@ type ReadableInterface = {
|
||||
|
||||
getPinnedMessagesForConversation: (
|
||||
conversationId: string
|
||||
) => ReadonlyArray<PinnedMessage>;
|
||||
) => ReadonlyArray<PinnedMessageRenderData>;
|
||||
getNextExpiringPinnedMessageAcrossConversations: () => PinnedMessage | null;
|
||||
|
||||
getMessagesNeedingUpgrade: (
|
||||
|
||||
@@ -49,7 +49,7 @@ import { isNormalNumber } from '../util/isNormalNumber.std.js';
|
||||
import { isNotNil } from '../util/isNotNil.std.js';
|
||||
import { parseIntOrThrow } from '../util/parseIntOrThrow.std.js';
|
||||
import { updateSchema } from './migrations/index.node.js';
|
||||
import type { JSONRows } from './util.std.js';
|
||||
import type { JSONRows, QueryFragment } from './util.std.js';
|
||||
import {
|
||||
batchMultiVarQuery,
|
||||
bulkAdd,
|
||||
@@ -68,7 +68,6 @@ import {
|
||||
sqlConstant,
|
||||
sqlFragment,
|
||||
sqlJoin,
|
||||
QueryFragment,
|
||||
convertOptionalBooleanToInteger,
|
||||
} from './util.std.js';
|
||||
import {
|
||||
@@ -199,6 +198,8 @@ import type {
|
||||
import {
|
||||
AttachmentDownloadSource,
|
||||
MESSAGE_COLUMNS,
|
||||
MESSAGE_COLUMNS_FRAGMENTS,
|
||||
MESSAGE_COLUMNS_SELECT,
|
||||
MESSAGE_ATTACHMENT_COLUMNS,
|
||||
MESSAGE_NON_PRIMARY_KEY_COLUMNS,
|
||||
} from './Interface.std.js';
|
||||
@@ -783,10 +784,6 @@ export const DataWriter: ServerWritableInterface = {
|
||||
runCorruptionChecks,
|
||||
};
|
||||
|
||||
const MESSAGE_COLUMNS_FRAGMENTS = MESSAGE_COLUMNS.map(
|
||||
column => new QueryFragment(column, [])
|
||||
);
|
||||
|
||||
function rowToConversation(row: ConversationRow): ConversationType {
|
||||
const { expireTimerVersion } = row;
|
||||
const parsedJson = JSON.parse(row.json);
|
||||
@@ -3534,7 +3531,7 @@ function getUnreadReactionsAndMarkRead(
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE reactions
|
||||
UPDATE reactions
|
||||
INDEXED BY reactions_unread
|
||||
SET unread = 0
|
||||
WHERE
|
||||
@@ -3782,7 +3779,7 @@ function getRecentStoryReplies(
|
||||
|
||||
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
|
||||
SELECT
|
||||
${sqlJoin(MESSAGE_COLUMNS_FRAGMENTS)}
|
||||
${MESSAGE_COLUMNS_SELECT}
|
||||
FROM messages
|
||||
WHERE
|
||||
(${messageId ?? null} IS NULL OR id IS NOT ${messageId ?? null}) AND
|
||||
|
||||
@@ -5,21 +5,65 @@ import type {
|
||||
PinnedMessage,
|
||||
PinnedMessageId,
|
||||
PinnedMessageParams,
|
||||
PinnedMessageRenderData,
|
||||
} from '../../types/PinnedMessage.std.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import type { ReadableDB, WritableDB } from '../Interface.std.js';
|
||||
import { hydrateMessage } from '../hydration.std.js';
|
||||
import type {
|
||||
MessageTypeUnhydrated,
|
||||
MessageType,
|
||||
ReadableDB,
|
||||
WritableDB,
|
||||
} from '../Interface.std.js';
|
||||
import { sql } from '../util.std.js';
|
||||
|
||||
function _getMessageById(
|
||||
db: ReadableDB,
|
||||
messageId: string
|
||||
): MessageType | null {
|
||||
const [query, params] = sql`
|
||||
SELECT * FROM messages
|
||||
WHERE id = ${messageId}
|
||||
`;
|
||||
|
||||
const row = db.prepare(query).get<MessageTypeUnhydrated>(params);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hydrateMessage(db, row);
|
||||
}
|
||||
|
||||
function _getPinnedMessageRenderData(
|
||||
db: ReadableDB,
|
||||
pinnedMessage: PinnedMessage
|
||||
): PinnedMessageRenderData {
|
||||
const message = _getMessageById(db, pinnedMessage.messageId);
|
||||
strictAssert(
|
||||
message != null,
|
||||
`Missing message ${pinnedMessage.messageId} for pinned message ${pinnedMessage.id}`
|
||||
);
|
||||
return { pinnedMessage, message };
|
||||
}
|
||||
|
||||
export function getPinnedMessagesForConversation(
|
||||
db: ReadableDB,
|
||||
conversationId: string
|
||||
): ReadonlyArray<PinnedMessage> {
|
||||
const [query, params] = sql`
|
||||
SELECT * FROM pinnedMessages
|
||||
WHERE conversationId = ${conversationId}
|
||||
ORDER BY pinnedAt DESC
|
||||
`;
|
||||
return db.prepare(query).all<PinnedMessage>(params);
|
||||
): ReadonlyArray<PinnedMessageRenderData> {
|
||||
return db.transaction(() => {
|
||||
const [query, params] = sql`
|
||||
SELECT * FROM pinnedMessages
|
||||
WHERE conversationId = ${conversationId}
|
||||
ORDER BY pinnedAt DESC
|
||||
`;
|
||||
|
||||
return db
|
||||
.prepare(query)
|
||||
.all<PinnedMessage>(params)
|
||||
.map(pinnedMessage => {
|
||||
return _getPinnedMessageRenderData(db, pinnedMessage);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function _getPinnedMessageByMessageId(
|
||||
|
||||
@@ -27,6 +27,7 @@ import { actions as mediaGallery } from './ducks/mediaGallery.preload.js';
|
||||
import { actions as nav } from './ducks/nav.std.js';
|
||||
import { actions as network } from './ducks/network.dom.js';
|
||||
import { actions as notificationProfiles } from './ducks/notificationProfiles.preload.js';
|
||||
import { actions as pinnedMessages } from './ducks/pinnedMessages.preload.js';
|
||||
import { actions as safetyNumber } from './ducks/safetyNumber.preload.js';
|
||||
import { actions as search } from './ducks/search.preload.js';
|
||||
import { actions as stickers } from './ducks/stickers.preload.js';
|
||||
@@ -65,6 +66,7 @@ export const actionCreators: ReduxActions = {
|
||||
nav,
|
||||
network,
|
||||
notificationProfiles,
|
||||
pinnedMessages,
|
||||
safetyNumber,
|
||||
search,
|
||||
stickers,
|
||||
|
||||
@@ -96,7 +96,6 @@ import {
|
||||
getPendingAvatarDownloadSelector,
|
||||
getAllConversations,
|
||||
getActivePanel,
|
||||
getSelectedConversationId,
|
||||
} from '../selectors/conversations.dom.js';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import type {
|
||||
@@ -244,10 +243,7 @@ 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';
|
||||
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
|
||||
|
||||
const {
|
||||
chunk,
|
||||
@@ -508,7 +504,7 @@ export type ConversationMessageType = ReadonlyDeep<{
|
||||
export type ConversationPreloadDataType = ReadonlyDeep<{
|
||||
conversationId: string;
|
||||
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessage>;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
|
||||
metrics: MessageMetricsType;
|
||||
unboundedFetch: boolean;
|
||||
}>;
|
||||
@@ -642,7 +638,6 @@ 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;
|
||||
|
||||
@@ -716,7 +711,9 @@ 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 const ADD_PRELOAD_DATA = 'conversations/ADD_PRELOAD_DATA';
|
||||
export const CONSUME_PRELOAD_DATA = 'conversations/CONSUME_PRELOAD_DATA';
|
||||
export const MESSAGES_RESET = 'conversations/MESSAGES_RESET';
|
||||
|
||||
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
|
||||
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
|
||||
@@ -952,7 +949,7 @@ export type RepairOldestMessageActionType = ReadonlyDeep<{
|
||||
};
|
||||
}>;
|
||||
export type MessagesResetActionType = ReadonlyDeep<{
|
||||
type: 'MESSAGES_RESET';
|
||||
type: typeof MESSAGES_RESET;
|
||||
payload: MessagesResetDataType;
|
||||
}>;
|
||||
export type SetMessageLoadingStateActionType = ReadonlyDeep<{
|
||||
@@ -1097,19 +1094,12 @@ 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';
|
||||
type: typeof ADD_PRELOAD_DATA;
|
||||
payload: ConversationPreloadDataType;
|
||||
}>;
|
||||
export type ConsumePreloadDataActionType = ReadonlyDeep<{
|
||||
type: 'CONSUME_PRELOAD_DATA';
|
||||
type: typeof CONSUME_PRELOAD_DATA;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
};
|
||||
@@ -1159,7 +1149,6 @@ export type ConversationActionType =
|
||||
| RepairNewestMessageActionType
|
||||
| RepairOldestMessageActionType
|
||||
| ReplaceAvatarsActionType
|
||||
| ReplacePinnedMessagesActionType
|
||||
| ReviewConversationNameCollisionActionType
|
||||
| ScrollToMessageActionType
|
||||
| SetPendingRequestedAvatarDownloadActionType
|
||||
@@ -1255,9 +1244,6 @@ export const actions = {
|
||||
onArchive,
|
||||
onMarkUnread,
|
||||
onMoveToInbox,
|
||||
onPinnedMessagesChanged,
|
||||
onPinnedMessageAdd,
|
||||
onPinnedMessageRemove,
|
||||
onUndoArchive,
|
||||
openGiftBadge,
|
||||
popPanelForConversation,
|
||||
@@ -3408,7 +3394,7 @@ function messagesReset({
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'MESSAGES_RESET',
|
||||
type: MESSAGES_RESET,
|
||||
payload: {
|
||||
unboundedFetch: unboundedFetch ?? false,
|
||||
conversationId,
|
||||
@@ -3432,7 +3418,7 @@ function addPreloadData(
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'ADD_PRELOAD_DATA',
|
||||
type: ADD_PRELOAD_DATA,
|
||||
payload: preloadData,
|
||||
};
|
||||
}
|
||||
@@ -3440,7 +3426,7 @@ function consumePreloadData(
|
||||
conversationId: string
|
||||
): ConsumePreloadDataActionType {
|
||||
return {
|
||||
type: 'CONSUME_PRELOAD_DATA',
|
||||
type: CONSUME_PRELOAD_DATA,
|
||||
payload: {
|
||||
conversationId,
|
||||
},
|
||||
@@ -5117,107 +5103,6 @@ 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 {
|
||||
@@ -5231,7 +5116,6 @@ export function getEmptyState(): ConversationsStateType {
|
||||
lastCenterMessageByConversation: {},
|
||||
messagesByConversation: {},
|
||||
messagesLookup: {},
|
||||
pinnedMessages: [],
|
||||
targetedMessage: undefined,
|
||||
targetedMessageCounter: 0,
|
||||
targetedMessageSource: undefined,
|
||||
@@ -5638,7 +5522,6 @@ function updateMessageLookup(
|
||||
conversationId,
|
||||
messages,
|
||||
metrics,
|
||||
pinnedMessages,
|
||||
scrollToMessageId,
|
||||
unboundedFetch,
|
||||
}: MessagesResetDataType
|
||||
@@ -5681,7 +5564,6 @@ function updateMessageLookup(
|
||||
targetedMessage: scrollToMessageId,
|
||||
targetedMessageCounter: state.targetedMessageCounter + 1,
|
||||
targetedMessageSource: TargetedMessageSource.Reset,
|
||||
pinnedMessages,
|
||||
}
|
||||
: {}),
|
||||
messagesLookup: {
|
||||
@@ -5999,7 +5881,6 @@ export function reducer(
|
||||
messagesByConversation: omit(state.messagesByConversation, [
|
||||
conversationId,
|
||||
]),
|
||||
pinnedMessages: [],
|
||||
};
|
||||
}
|
||||
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
|
||||
@@ -6431,16 +6312,16 @@ export function reducer(
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'MESSAGES_RESET') {
|
||||
if (action.type === MESSAGES_RESET) {
|
||||
return updateMessageLookup(state, action.payload);
|
||||
}
|
||||
if (action.type === 'ADD_PRELOAD_DATA') {
|
||||
if (action.type === ADD_PRELOAD_DATA) {
|
||||
return {
|
||||
...state,
|
||||
preloadData: action.payload,
|
||||
};
|
||||
}
|
||||
if (action.type === 'CONSUME_PRELOAD_DATA') {
|
||||
if (action.type === CONSUME_PRELOAD_DATA) {
|
||||
const { preloadData, selectedConversationId } = state;
|
||||
const { conversationId } = action.payload;
|
||||
if (!preloadData) {
|
||||
@@ -7593,21 +7474,6 @@ 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
|
||||
|
||||
299
ts/state/ducks/pinnedMessages.preload.ts
Normal file
299
ts/state/ducks/pinnedMessages.preload.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.js';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions.std.js';
|
||||
import { DataReader, DataWriter } from '../../sql/Client.preload.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
|
||||
import type { ReadonlyMessageAttributesType } from '../../model-types.js';
|
||||
import type { AciString } from '../../types/ServiceId.std.js';
|
||||
import { isIncoming } from '../selectors/message.preload.js';
|
||||
import { isAciString } from '../../util/isAciString.std.js';
|
||||
import { itemStorage } from '../../textsecure/Storage.preload.js';
|
||||
import type { StateThunk } from '../types.std.js';
|
||||
import type { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js';
|
||||
import {
|
||||
conversationJobQueue,
|
||||
conversationQueueJobEnum,
|
||||
} from '../../jobs/conversationJobQueue.preload.js';
|
||||
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js';
|
||||
import { getPinnedMessageExpiresAt } from '../../util/pinnedMessages.std.js';
|
||||
import { getSelectedConversationId } from '../selectors/conversations.dom.js';
|
||||
import type {
|
||||
AddPreloadDataActionType,
|
||||
ConsumePreloadDataActionType,
|
||||
ConversationUnloadedActionType,
|
||||
MessageChangedActionType,
|
||||
MessagesResetActionType,
|
||||
TargetedConversationChangedActionType,
|
||||
} from './conversations.preload.js';
|
||||
import {
|
||||
ADD_PRELOAD_DATA,
|
||||
CONSUME_PRELOAD_DATA,
|
||||
CONVERSATION_UNLOADED,
|
||||
MESSAGE_CHANGED,
|
||||
TARGETED_CONVERSATION_CHANGED,
|
||||
} from './conversations.preload.js';
|
||||
|
||||
type PreloadData = ReadonlyDeep<{
|
||||
conversationId: string;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
|
||||
}>;
|
||||
|
||||
export type PinnedMessagesState = ReadonlyDeep<{
|
||||
preloadData: PreloadData | null;
|
||||
conversationId: string | null;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData> | null;
|
||||
}>;
|
||||
|
||||
const PINNED_MESSAGES_REPLACE = 'pinnedMessages/PINNED_MESSAGES_REPLACE';
|
||||
|
||||
export type PinnedMessagesReplace = ReadonlyDeep<{
|
||||
type: typeof PINNED_MESSAGES_REPLACE;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type PinnedMessagesAction = ReadonlyDeep<PinnedMessagesReplace>;
|
||||
|
||||
export function getEmptyState(): PinnedMessagesState {
|
||||
return {
|
||||
preloadData: null,
|
||||
conversationId: null,
|
||||
pinnedMessages: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getMessageAuthorAci(
|
||||
message: ReadonlyMessageAttributesType
|
||||
): AciString {
|
||||
if (isIncoming(message)) {
|
||||
strictAssert(
|
||||
isAciString(message.sourceServiceId),
|
||||
'Message sourceServiceId must be an ACI'
|
||||
);
|
||||
return message.sourceServiceId;
|
||||
}
|
||||
return itemStorage.user.getCheckedAci();
|
||||
}
|
||||
|
||||
type PinnedMessageTarget = ReadonlyDeep<{
|
||||
conversationId: string;
|
||||
targetMessageId: string;
|
||||
targetAuthorAci: AciString;
|
||||
targetSentTimestamp: number;
|
||||
}>;
|
||||
|
||||
async function getPinnedMessageTarget(
|
||||
targetMessageId: string
|
||||
): Promise<PinnedMessageTarget> {
|
||||
const message = await DataReader.getMessageById(targetMessageId);
|
||||
if (message == null) {
|
||||
throw new Error('getPinnedMessageTarget: Target message not found');
|
||||
}
|
||||
return {
|
||||
conversationId: message.conversationId,
|
||||
targetMessageId: message.id,
|
||||
targetAuthorAci: getMessageAuthorAci(message),
|
||||
targetSentTimestamp: message.sent_at,
|
||||
};
|
||||
}
|
||||
|
||||
function onPinnedMessagesChanged(
|
||||
conversationId: string
|
||||
): StateThunk<PinnedMessagesReplace> {
|
||||
return async (dispatch, getState) => {
|
||||
const selectedConversationId = getSelectedConversationId(getState());
|
||||
if (
|
||||
selectedConversationId == null ||
|
||||
selectedConversationId !== conversationId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pinnedMessages =
|
||||
await DataReader.getPinnedMessagesForConversation(conversationId);
|
||||
|
||||
dispatch({
|
||||
type: PINNED_MESSAGES_REPLACE,
|
||||
payload: {
|
||||
conversationId,
|
||||
pinnedMessages,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function onPinnedMessageAdd(
|
||||
targetMessageId: string,
|
||||
pinDurationSeconds: DurationInSeconds | null
|
||||
): StateThunk {
|
||||
return async dispatch => {
|
||||
const target = await getPinnedMessageTarget(targetMessageId);
|
||||
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));
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
onPinnedMessagesChanged,
|
||||
onPinnedMessageAdd,
|
||||
onPinnedMessageRemove,
|
||||
};
|
||||
|
||||
export const usePinnedMessagesActions = (): BoundActionCreatorsMapObject<
|
||||
typeof actions
|
||||
> => useBoundActions(actions);
|
||||
|
||||
function updateMessageInPinnedMessages(
|
||||
pinnedMessages: ReadonlyArray<PinnedMessageRenderData>,
|
||||
message: ReadonlyMessageAttributesType
|
||||
): ReadonlyArray<PinnedMessageRenderData> {
|
||||
return pinnedMessages.map(pinnedMessage => {
|
||||
if (pinnedMessage.message.id === message.id) {
|
||||
return { ...pinnedMessage, message };
|
||||
}
|
||||
return pinnedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: PinnedMessagesState = getEmptyState(),
|
||||
action:
|
||||
| PinnedMessagesAction
|
||||
| MessageChangedActionType
|
||||
| TargetedConversationChangedActionType
|
||||
| ConversationUnloadedActionType
|
||||
| AddPreloadDataActionType
|
||||
| ConsumePreloadDataActionType
|
||||
| MessagesResetActionType
|
||||
): PinnedMessagesState {
|
||||
switch (action.type) {
|
||||
case PINNED_MESSAGES_REPLACE:
|
||||
if (state.conversationId !== action.payload.conversationId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
pinnedMessages: action.payload.pinnedMessages,
|
||||
};
|
||||
case TARGETED_CONVERSATION_CHANGED: {
|
||||
const conversationId = action.payload.conversationId ?? null;
|
||||
return {
|
||||
...state,
|
||||
preloadData:
|
||||
conversationId != null &&
|
||||
state.preloadData != null &&
|
||||
state.preloadData.conversationId === conversationId
|
||||
? state.preloadData
|
||||
: null,
|
||||
conversationId,
|
||||
pinnedMessages: null,
|
||||
};
|
||||
}
|
||||
case CONVERSATION_UNLOADED:
|
||||
if (state.conversationId !== action.payload.conversationId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
conversationId: null,
|
||||
pinnedMessages: null,
|
||||
};
|
||||
case ADD_PRELOAD_DATA:
|
||||
return {
|
||||
...state,
|
||||
preloadData: {
|
||||
conversationId: action.payload.conversationId,
|
||||
pinnedMessages: action.payload.pinnedMessages,
|
||||
},
|
||||
};
|
||||
case CONSUME_PRELOAD_DATA:
|
||||
if (state.preloadData == null) {
|
||||
return state;
|
||||
}
|
||||
if (state.preloadData.conversationId === action.payload.conversationId) {
|
||||
return {
|
||||
...state,
|
||||
preloadData: null,
|
||||
conversationId: state.preloadData.conversationId,
|
||||
pinnedMessages: state.preloadData.pinnedMessages,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
preloadData: null,
|
||||
};
|
||||
case MESSAGE_CHANGED: {
|
||||
let nextState = state;
|
||||
|
||||
if (
|
||||
nextState.conversationId === action.payload.conversationId &&
|
||||
nextState.pinnedMessages != null
|
||||
) {
|
||||
nextState = {
|
||||
...nextState,
|
||||
pinnedMessages: updateMessageInPinnedMessages(
|
||||
nextState.pinnedMessages,
|
||||
action.payload.data
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
nextState.preloadData != null &&
|
||||
nextState.preloadData.conversationId === action.payload.id
|
||||
) {
|
||||
nextState = {
|
||||
...nextState,
|
||||
preloadData: {
|
||||
...nextState.preloadData,
|
||||
pinnedMessages: updateMessageInPinnedMessages(
|
||||
nextState.preloadData.pinnedMessages,
|
||||
action.payload.data
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { getEmptyState as mediaGalleryEmptyState } from './ducks/mediaGallery.pr
|
||||
import { getEmptyState as navEmptyState } from './ducks/nav.std.js';
|
||||
import { getEmptyState as networkEmptyState } from './ducks/network.dom.js';
|
||||
import { getEmptyState as notificationProfilesEmptyState } from './ducks/notificationProfiles.preload.js';
|
||||
import { getEmptyState as pinnedMessagesEmptyState } from './ducks/pinnedMessages.preload.js';
|
||||
import { getEmptyState as preferredReactionsEmptyState } from './ducks/preferredReactions.preload.js';
|
||||
import { getEmptyState as safetyNumberEmptyState } from './ducks/safetyNumber.preload.js';
|
||||
import { getEmptyState as searchEmptyState } from './ducks/search.preload.js';
|
||||
@@ -172,6 +173,7 @@ function getEmptyState(): StateType {
|
||||
nav: navEmptyState(),
|
||||
network: networkEmptyState(),
|
||||
notificationProfiles: notificationProfilesEmptyState(),
|
||||
pinnedMessages: pinnedMessagesEmptyState(),
|
||||
preferredReactions: preferredReactionsEmptyState(),
|
||||
safetyNumber: safetyNumberEmptyState(),
|
||||
search: searchEmptyState(),
|
||||
|
||||
@@ -91,6 +91,10 @@ export function initializeRedux(data: ReduxInitData): void {
|
||||
),
|
||||
nav: bindActionCreators(actionCreators.nav, store.dispatch),
|
||||
network: bindActionCreators(actionCreators.network, store.dispatch),
|
||||
pinnedMessages: bindActionCreators(
|
||||
actionCreators.pinnedMessages,
|
||||
store.dispatch
|
||||
),
|
||||
notificationProfiles: bindActionCreators(
|
||||
actionCreators.notificationProfiles,
|
||||
store.dispatch
|
||||
|
||||
@@ -29,6 +29,7 @@ import { reducer as mediaGallery } from './ducks/mediaGallery.preload.js';
|
||||
import { reducer as nav } from './ducks/nav.std.js';
|
||||
import { reducer as network } from './ducks/network.dom.js';
|
||||
import { reducer as notificationProfiles } from './ducks/notificationProfiles.preload.js';
|
||||
import { reducer as pinnedMessages } from './ducks/pinnedMessages.preload.js';
|
||||
import { reducer as preferredReactions } from './ducks/preferredReactions.preload.js';
|
||||
import { reducer as safetyNumber } from './ducks/safetyNumber.preload.js';
|
||||
import { reducer as search } from './ducks/search.preload.js';
|
||||
@@ -67,6 +68,7 @@ export const reducer = combineReducers({
|
||||
nav,
|
||||
network,
|
||||
notificationProfiles,
|
||||
pinnedMessages,
|
||||
preferredReactions,
|
||||
safetyNumber,
|
||||
search,
|
||||
|
||||
@@ -231,14 +231,7 @@ 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,7 +103,6 @@ import {
|
||||
getMessages,
|
||||
getCachedConversationMemberColorsSelector,
|
||||
getContactNameColor,
|
||||
getPinnedMessageIds,
|
||||
} from './conversations.dom.js';
|
||||
import {
|
||||
getIntl,
|
||||
@@ -169,6 +168,7 @@ import { LONG_MESSAGE } from '../../types/MIME.std.js';
|
||||
import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification.dom.js';
|
||||
import type { PinnedMessageNotificationData } from '../../components/conversation/pinned-messages/PinnedMessageNotification.dom.js';
|
||||
import { canEditGroupInfo } from '../../util/canEditGroupInfo.preload.js';
|
||||
import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js';
|
||||
|
||||
const { groupBy, isEmpty, isNumber, isObject, map } = lodash;
|
||||
|
||||
@@ -206,7 +206,7 @@ export type GetPropsForBubbleOptions = Readonly<{
|
||||
ourPni: PniString | undefined;
|
||||
targetedMessageId?: string;
|
||||
targetedMessageCounter?: number;
|
||||
pinnedMessageIds: ReadonlyArray<string> | null;
|
||||
pinnedMessagesMessageIds: ReadonlyArray<string> | null;
|
||||
selectedMessageIds: ReadonlyArray<string> | undefined;
|
||||
regionCode?: string;
|
||||
callSelector: CallSelectorType;
|
||||
@@ -778,7 +778,7 @@ export type GetPropsForMessageOptions = Pick<
|
||||
| 'ourNumber'
|
||||
| 'targetedMessageId'
|
||||
| 'targetedMessageCounter'
|
||||
| 'pinnedMessageIds'
|
||||
| 'pinnedMessagesMessageIds'
|
||||
| 'selectedMessageIds'
|
||||
| 'regionCode'
|
||||
| 'accountSelector'
|
||||
@@ -857,7 +857,7 @@ function getTextDirection(body?: string): TextDirection {
|
||||
export const getPropsForMessage = (
|
||||
message: MessageWithUIFieldsType,
|
||||
options: GetPropsForMessageOptions
|
||||
): Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> => {
|
||||
): MessagePropsType => {
|
||||
const attachmentDroppedDueToSize = message.attachments?.some(
|
||||
item => item.wasTooBig
|
||||
);
|
||||
@@ -881,7 +881,7 @@ export const getPropsForMessage = (
|
||||
regionCode,
|
||||
targetedMessageId,
|
||||
targetedMessageCounter,
|
||||
pinnedMessageIds,
|
||||
pinnedMessagesMessageIds,
|
||||
selectedMessageIds,
|
||||
contactNameColors,
|
||||
defaultConversationColor,
|
||||
@@ -900,7 +900,7 @@ export const getPropsForMessage = (
|
||||
const activeCallConversationId = activeCall?.conversationId;
|
||||
|
||||
const isTargeted = message.id === targetedMessageId;
|
||||
const isPinned = pinnedMessageIds?.includes(message.id) ?? false;
|
||||
const isPinned = pinnedMessagesMessageIds?.includes(message.id) ?? false;
|
||||
const isSelected = selectedMessageIds?.includes(message.id) ?? false;
|
||||
const isSelectMode = selectedMessageIds != null;
|
||||
|
||||
@@ -1011,7 +1011,7 @@ export const getMessagePropsSelector = createSelector(
|
||||
getAccountSelector,
|
||||
getCachedConversationMemberColorsSelector,
|
||||
getTargetedMessage,
|
||||
getPinnedMessageIds,
|
||||
getPinnedMessagesMessageIds,
|
||||
getSelectedMessageIds,
|
||||
getDefaultConversationColor,
|
||||
(
|
||||
@@ -1024,7 +1024,7 @@ export const getMessagePropsSelector = createSelector(
|
||||
accountSelector,
|
||||
cachedConversationMemberColorsSelector,
|
||||
targetedMessage,
|
||||
pinnedMessageIds,
|
||||
pinnedMessagesMessageIds,
|
||||
selectedMessageIds,
|
||||
defaultConversationColor
|
||||
) =>
|
||||
@@ -1043,7 +1043,7 @@ export const getMessagePropsSelector = createSelector(
|
||||
regionCode,
|
||||
targetedMessageCounter: targetedMessage?.counter,
|
||||
targetedMessageId: targetedMessage?.id,
|
||||
pinnedMessageIds,
|
||||
pinnedMessagesMessageIds,
|
||||
selectedMessageIds,
|
||||
defaultConversationColor,
|
||||
});
|
||||
@@ -2403,7 +2403,7 @@ export function canForward(message: ReadonlyMessageAttributesType): boolean {
|
||||
}
|
||||
|
||||
export function canPinMessages(conversation: ConversationType): boolean {
|
||||
return canEditGroupInfo(conversation);
|
||||
return conversation.type === 'direct' || canEditGroupInfo(conversation);
|
||||
}
|
||||
|
||||
export function getLastChallengeError(
|
||||
@@ -2445,7 +2445,7 @@ export const getMessageDetails = createSelector(
|
||||
getUserPNI,
|
||||
getUserConversationId,
|
||||
getUserNumber,
|
||||
getPinnedMessageIds,
|
||||
getPinnedMessagesMessageIds,
|
||||
getSelectedMessageIds,
|
||||
getDefaultConversationColor,
|
||||
getHasUnidentifiedDeliveryIndicators,
|
||||
@@ -2460,7 +2460,7 @@ export const getMessageDetails = createSelector(
|
||||
ourPni,
|
||||
ourConversationId,
|
||||
ourNumber,
|
||||
pinnedMessageIds,
|
||||
pinnedMessagesMessageIds,
|
||||
selectedMessageIds,
|
||||
defaultConversationColor,
|
||||
hasUnidentifiedDeliveryIndicators
|
||||
@@ -2595,7 +2595,7 @@ export const getMessageDetails = createSelector(
|
||||
ourConversationId,
|
||||
ourNumber,
|
||||
regionCode,
|
||||
pinnedMessageIds,
|
||||
pinnedMessagesMessageIds,
|
||||
selectedMessageIds,
|
||||
defaultConversationColor,
|
||||
}),
|
||||
|
||||
39
ts/state/selectors/pinnedMessages.dom.ts
Normal file
39
ts/state/selectors/pinnedMessages.dom.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
import type { PinnedMessageRenderData } from '../../types/PinnedMessage.std.js';
|
||||
import type { PinnedMessagesState } from '../ducks/pinnedMessages.preload.js';
|
||||
import type { StateType } from '../reducer.preload.js';
|
||||
import type { StateSelector } from '../types.std.js';
|
||||
import { getSelectedConversationId } from './conversations.dom.js';
|
||||
import { softAssert } from '../../util/assert.std.js';
|
||||
|
||||
export function getPinnedMessagesState(state: StateType): PinnedMessagesState {
|
||||
return state.pinnedMessages;
|
||||
}
|
||||
|
||||
export const getPinnedMessages: StateSelector<
|
||||
ReadonlyArray<PinnedMessageRenderData>
|
||||
> = createSelector(
|
||||
getPinnedMessagesState,
|
||||
getSelectedConversationId,
|
||||
(state, selectedConversationId) => {
|
||||
const expectedConversationId = selectedConversationId ?? null;
|
||||
if (expectedConversationId !== state.conversationId) {
|
||||
softAssert(
|
||||
false,
|
||||
'getPinnedMessages: State is not in sync with the selected conversation'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return state.pinnedMessages ?? [];
|
||||
}
|
||||
);
|
||||
|
||||
export const getPinnedMessagesMessageIds: StateSelector<ReadonlyArray<string>> =
|
||||
createSelector(getPinnedMessages, pinnedMessages => {
|
||||
return pinnedMessages.map(pinnedMessage => {
|
||||
return pinnedMessage.message.id;
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getSelectedMessageIds,
|
||||
getMessages,
|
||||
getCachedConversationMemberColorsSelector,
|
||||
getPinnedMessageIds,
|
||||
} from './conversations.dom.js';
|
||||
import { getAccountSelector } from './accounts.std.js';
|
||||
import {
|
||||
@@ -26,6 +25,7 @@ import { getActiveCall, getCallSelector } from './calling.std.js';
|
||||
import { getPropsForBubble } from './message.preload.js';
|
||||
import { getCallHistorySelector } from './callHistory.std.js';
|
||||
import { useProxySelector } from '../../hooks/useProxySelector.std.js';
|
||||
import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js';
|
||||
|
||||
const getTimelineItem = (
|
||||
state: StateType,
|
||||
@@ -54,7 +54,7 @@ const getTimelineItem = (
|
||||
const callHistorySelector = getCallHistorySelector(state);
|
||||
const activeCall = getActiveCall(state);
|
||||
const accountSelector = getAccountSelector(state);
|
||||
const pinnedMessageIds = getPinnedMessageIds(state);
|
||||
const pinnedMessagesMessageIds = getPinnedMessagesMessageIds(state);
|
||||
const selectedMessageIds = getSelectedMessageIds(state);
|
||||
const defaultConversationColor = getDefaultConversationColor(state);
|
||||
|
||||
@@ -72,7 +72,7 @@ const getTimelineItem = (
|
||||
callHistorySelector,
|
||||
activeCall,
|
||||
accountSelector,
|
||||
pinnedMessageIds,
|
||||
pinnedMessagesMessageIds,
|
||||
selectedMessageIds,
|
||||
defaultConversationColor,
|
||||
});
|
||||
|
||||
208
ts/state/smart/PinnedMessagesBar.preload.tsx
Normal file
208
ts/state/smart/PinnedMessagesBar.preload.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import { getSelectedConversationId } from '../selectors/conversations.dom.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import { useConversationsActions } from '../ducks/conversations.preload.js';
|
||||
import type {
|
||||
Pin,
|
||||
PinMessage,
|
||||
PinMessageAttachment,
|
||||
PinMessageContact,
|
||||
PinMessagePoll,
|
||||
PinMessageText,
|
||||
PinSender,
|
||||
} from '../../components/conversation/pinned-messages/PinnedMessagesBar.dom.js';
|
||||
import { PinnedMessagesBar } from '../../components/conversation/pinned-messages/PinnedMessagesBar.dom.js';
|
||||
import { PanelType } from '../../types/Panels.std.js';
|
||||
import type { PinnedMessageId } from '../../types/PinnedMessage.std.js';
|
||||
import {
|
||||
getMessagePropsSelector,
|
||||
type MessagePropsType,
|
||||
} from '../selectors/message.preload.js';
|
||||
import * as Attachment from '../../util/Attachment.std.js';
|
||||
import * as MIME from '../../types/MIME.std.js';
|
||||
import * as EmbeddedContact from '../../types/EmbeddedContact.std.js';
|
||||
import type { StateSelector } from '../types.std.js';
|
||||
import { usePinnedMessagesActions } from '../ducks/pinnedMessages.preload.js';
|
||||
import { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
|
||||
|
||||
function getPinMessageAttachment(
|
||||
props: MessagePropsType
|
||||
): PinMessageAttachment | null {
|
||||
const attachment = props.attachments?.at(0);
|
||||
if (attachment == null) {
|
||||
return null;
|
||||
}
|
||||
const { contentType } = attachment;
|
||||
if (contentType === MIME.IMAGE_GIF || Attachment.isGIF([attachment])) {
|
||||
return { type: 'gif' };
|
||||
}
|
||||
if (Attachment.isImage([attachment])) {
|
||||
return { type: 'image', url: attachment.thumbnail?.url ?? null };
|
||||
}
|
||||
if (Attachment.isVideo([attachment])) {
|
||||
return { type: 'video', url: attachment.thumbnail?.url ?? null };
|
||||
}
|
||||
if (Attachment.isVoiceMessage(attachment)) {
|
||||
return { type: 'voiceMessage' };
|
||||
}
|
||||
return { type: 'file', name: attachment.fileName ?? null };
|
||||
}
|
||||
|
||||
function getPinMessageText(props: MessagePropsType): PinMessageText | null {
|
||||
if (props.text == null) {
|
||||
return null;
|
||||
}
|
||||
return { body: props.text, bodyRanges: props.bodyRanges ?? [] };
|
||||
}
|
||||
|
||||
function getPinMessageContact(
|
||||
props: MessagePropsType
|
||||
): PinMessageContact | null {
|
||||
if (props.contact == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: EmbeddedContact.getDisplayName(props.contact) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isPinMessagePayment(props: MessagePropsType): boolean {
|
||||
return props.payment != null;
|
||||
}
|
||||
|
||||
function getPinMessagePoll(props: MessagePropsType): PinMessagePoll | null {
|
||||
if (props.poll == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
question: props.poll.question,
|
||||
};
|
||||
}
|
||||
|
||||
function isPinMessageSticker(props: MessagePropsType): boolean {
|
||||
return props.isSticker ?? false;
|
||||
}
|
||||
|
||||
function getPinMessage(props: MessagePropsType): PinMessage {
|
||||
return {
|
||||
id: props.id,
|
||||
text: getPinMessageText(props),
|
||||
attachment: getPinMessageAttachment(props),
|
||||
contact: getPinMessageContact(props),
|
||||
payment: isPinMessagePayment(props),
|
||||
poll: getPinMessagePoll(props),
|
||||
sticker: isPinMessageSticker(props),
|
||||
};
|
||||
}
|
||||
|
||||
function getPinSender(props: MessagePropsType): PinSender {
|
||||
return {
|
||||
id: props.author.id,
|
||||
title: props.author.title,
|
||||
isMe: props.author.isMe,
|
||||
};
|
||||
}
|
||||
|
||||
const selectPins: StateSelector<ReadonlyArray<Pin>> = createSelector(
|
||||
getPinnedMessages,
|
||||
getMessagePropsSelector,
|
||||
(pinnedMessages, messagePropsSelector) => {
|
||||
return pinnedMessages.map((pinnedMessageRenderData): Pin => {
|
||||
const { pinnedMessage, message } = pinnedMessageRenderData;
|
||||
const messageProps = messagePropsSelector(message);
|
||||
|
||||
return {
|
||||
id: pinnedMessage.id,
|
||||
sender: getPinSender(messageProps),
|
||||
message: getPinMessage(messageProps),
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() {
|
||||
const i18n = useSelector(getIntl);
|
||||
const conversationId = useSelector(getSelectedConversationId);
|
||||
const pins = useSelector(selectPins);
|
||||
|
||||
strictAssert(
|
||||
conversationId != null,
|
||||
'PinnedMessagesBar should only be rendered in selected conversation'
|
||||
);
|
||||
|
||||
const { pushPanelForConversation, scrollToMessage } =
|
||||
useConversationsActions();
|
||||
const { onPinnedMessageRemove } = usePinnedMessagesActions();
|
||||
|
||||
const [current, setCurrent] = useState(() => {
|
||||
return pins.at(0)?.id ?? null;
|
||||
});
|
||||
|
||||
const isCurrentOutOfDate = useMemo(() => {
|
||||
if (current == null) {
|
||||
if (pins.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasMatch = pins.some(pin => {
|
||||
return pin.id === current;
|
||||
});
|
||||
|
||||
return !hasMatch;
|
||||
}, [current, pins]);
|
||||
|
||||
if (isCurrentOutOfDate) {
|
||||
setCurrent(pins.at(0)?.id ?? null);
|
||||
}
|
||||
|
||||
const handleCurrentChange = useCallback(
|
||||
(pinnedMessageId: PinnedMessageId) => {
|
||||
setCurrent(pinnedMessageId);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePinGoTo = useCallback(
|
||||
(messageId: string) => {
|
||||
scrollToMessage(conversationId, messageId);
|
||||
},
|
||||
[scrollToMessage, conversationId]
|
||||
);
|
||||
|
||||
const handlePinRemove = useCallback(
|
||||
(messageId: string) => {
|
||||
onPinnedMessageRemove(messageId);
|
||||
},
|
||||
[onPinnedMessageRemove]
|
||||
);
|
||||
|
||||
const handlePinsShowAll = useCallback(() => {
|
||||
pushPanelForConversation({
|
||||
type: PanelType.PinnedMessages,
|
||||
});
|
||||
}, [pushPanelForConversation]);
|
||||
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<PinnedMessagesBar
|
||||
i18n={i18n}
|
||||
pins={pins}
|
||||
current={current}
|
||||
onCurrentChange={handleCurrentChange}
|
||||
onPinGoTo={handlePinGoTo}
|
||||
onPinRemove={handlePinRemove}
|
||||
onPinsShowAll={handlePinsShowAll}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -3,19 +3,13 @@
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { getIntl } from '../selectors/user.std.js';
|
||||
import { getConversationByIdSelector } from '../selectors/conversations.dom.js';
|
||||
import { strictAssert } from '../../util/assert.std.js';
|
||||
import { PinnedMessagesPanel } from '../../components/conversation/pinned-messages/PinnedMessagesPanel.dom.js';
|
||||
import type { SmartTimelineItemProps } from './TimelineItem.preload.js';
|
||||
import { SmartTimelineItem } from './TimelineItem.preload.js';
|
||||
import type { StateSelector } from '../types.std.js';
|
||||
import type {
|
||||
PinnedMessage,
|
||||
PinnedMessageId,
|
||||
} from '../../types/PinnedMessage.std.js';
|
||||
import type { StateType } from '../reducer.preload.js';
|
||||
import { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
|
||||
|
||||
export type SmartPinnedMessagesPanelProps = Readonly<{
|
||||
conversationId: string;
|
||||
@@ -25,39 +19,6 @@ function renderTimelineItem(props: SmartTimelineItemProps) {
|
||||
return <SmartTimelineItem {...props} />;
|
||||
}
|
||||
|
||||
const mockSelectPinnedMessages: StateSelector<ReadonlyArray<PinnedMessage>> =
|
||||
createSelector(
|
||||
(state: StateType) => state.conversations,
|
||||
conversations => {
|
||||
const selectedConversationId =
|
||||
conversations.selectedConversationId ?? null;
|
||||
if (selectedConversationId == null) {
|
||||
throw new Error();
|
||||
}
|
||||
const messageIds =
|
||||
conversations.messagesByConversation[selectedConversationId]
|
||||
?.messageIds ?? [];
|
||||
|
||||
return messageIds
|
||||
.map(messageId => {
|
||||
return conversations.messagesLookup[messageId] ?? null;
|
||||
})
|
||||
.filter(message => {
|
||||
return message.type === 'incoming' || message.type === 'outgoing';
|
||||
})
|
||||
.slice(-10)
|
||||
.map((message, messageIndex): PinnedMessage => {
|
||||
return {
|
||||
id: messageIndex as PinnedMessageId,
|
||||
conversationId: selectedConversationId,
|
||||
messageId: message.id,
|
||||
pinnedAt: Date.now(),
|
||||
expiresAt: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const SmartPinnedMessagesPanel = memo(function SmartPinnedMessagesPanel(
|
||||
props: SmartPinnedMessagesPanelProps
|
||||
) {
|
||||
@@ -70,13 +31,13 @@ export const SmartPinnedMessagesPanel = memo(function SmartPinnedMessagesPanel(
|
||||
'<SmartPinnedMessagesPanel> expected a conversation to be found'
|
||||
);
|
||||
|
||||
const mockPinnedMessages = useSelector(mockSelectPinnedMessages);
|
||||
const pinnedMessages = useSelector(getPinnedMessages);
|
||||
|
||||
return (
|
||||
<PinnedMessagesPanel
|
||||
i18n={i18n}
|
||||
conversation={conversation}
|
||||
pinnedMessages={mockPinnedMessages}
|
||||
pinnedMessages={pinnedMessages}
|
||||
renderTimelineItem={renderTimelineItem}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
import { SmartTypingBubble } from './TypingBubble.preload.js';
|
||||
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager.preload.js';
|
||||
import { isInFullScreenCall as getIsInFullScreenCall } from '../selectors/calling.std.js';
|
||||
import { SmartPinnedMessagesBar } from './PinnedMessagesBar.preload.js';
|
||||
import { getPinnedMessages } from '../selectors/pinnedMessages.dom.js';
|
||||
|
||||
const { isEmpty } = lodash;
|
||||
|
||||
@@ -102,6 +104,9 @@ function renderHeroRow(id: string): JSX.Element {
|
||||
function renderMiniPlayer(options: { shouldFlow: boolean }): JSX.Element {
|
||||
return <SmartMiniPlayer {...options} />;
|
||||
}
|
||||
function renderPinnedMessagesBar(): JSX.Element {
|
||||
return <SmartPinnedMessagesBar />;
|
||||
}
|
||||
function renderTypingBubble(conversationId: string): JSX.Element {
|
||||
return <SmartTypingBubble conversationId={conversationId} />;
|
||||
}
|
||||
@@ -177,6 +182,7 @@ export const SmartTimeline = memo(function SmartTimeline({
|
||||
const isInFullScreenCall = useSelector(getIsInFullScreenCall);
|
||||
const conversation = conversationSelector(id);
|
||||
const conversationMessages = conversationMessagesSelector(id);
|
||||
const pinnedMessages = useSelector(getPinnedMessages);
|
||||
|
||||
const warning = useSelector(
|
||||
useCallback(
|
||||
@@ -214,6 +220,7 @@ export const SmartTimeline = memo(function SmartTimeline({
|
||||
);
|
||||
|
||||
const shouldShowMiniPlayer = activeAudioPlayer != null;
|
||||
const shouldShowPinnedMessagesBar = pinnedMessages.length > 0;
|
||||
const {
|
||||
acceptedMessageRequest,
|
||||
isBlocked = false,
|
||||
@@ -288,6 +295,7 @@ export const SmartTimeline = memo(function SmartTimeline({
|
||||
renderHeroRow={renderHeroRow}
|
||||
renderItem={renderItem}
|
||||
renderMiniPlayer={renderMiniPlayer}
|
||||
renderPinnedMessagesBar={renderPinnedMessagesBar}
|
||||
renderTypingBubble={renderTypingBubble}
|
||||
reviewConversationNameCollision={reviewConversationNameCollision}
|
||||
scrollToIndex={scrollToIndex}
|
||||
@@ -296,6 +304,7 @@ export const SmartTimeline = memo(function SmartTimeline({
|
||||
setCenterMessage={setCenterMessage}
|
||||
setIsNearBottom={setIsNearBottom}
|
||||
shouldShowMiniPlayer={shouldShowMiniPlayer}
|
||||
shouldShowPinnedMessagesBar={shouldShowPinnedMessagesBar}
|
||||
targetedMessageId={targetedMessageId}
|
||||
targetMessage={targetMessage}
|
||||
theme={theme}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { renderReactionPicker } from './renderReactionPicker.dom.js';
|
||||
import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation.dom.js';
|
||||
import { TargetedMessageSource } from '../ducks/conversationsEnums.std.js';
|
||||
import type { MessageInteractivity } from '../../components/conversation/Message.dom.js';
|
||||
import { usePinnedMessagesActions } from '../ducks/pinnedMessages.preload.js';
|
||||
|
||||
export type SmartTimelineItemProps = {
|
||||
containerElementRef: RefObject<HTMLElement>;
|
||||
@@ -131,8 +132,6 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
|
||||
kickOffAttachmentDownload,
|
||||
markAttachmentAsCorrupted,
|
||||
messageExpanded,
|
||||
onPinnedMessageAdd,
|
||||
onPinnedMessageRemove,
|
||||
openGiftBadge,
|
||||
pushPanelForConversation,
|
||||
retryDeleteForEveryone,
|
||||
@@ -152,6 +151,9 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
|
||||
toggleSelectMessage,
|
||||
} = useConversationsActions();
|
||||
|
||||
const { onPinnedMessageAdd, onPinnedMessageRemove } =
|
||||
usePinnedMessagesActions();
|
||||
|
||||
const {
|
||||
endPoll,
|
||||
reactToMessage,
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { actions as mediaGallery } from './ducks/mediaGallery.preload.js';
|
||||
import type { actions as nav } from './ducks/nav.std.js';
|
||||
import type { actions as network } from './ducks/network.dom.js';
|
||||
import type { actions as notificationProfiles } from './ducks/notificationProfiles.preload.js';
|
||||
import type { actions as pinnedMessages } from './ducks/pinnedMessages.preload.js';
|
||||
import type { actions as safetyNumber } from './ducks/safetyNumber.preload.js';
|
||||
import type { actions as search } from './ducks/search.preload.js';
|
||||
import type { actions as stickers } from './ducks/stickers.preload.js';
|
||||
@@ -68,6 +69,7 @@ export type ReduxActions = {
|
||||
nav: typeof nav;
|
||||
network: typeof network;
|
||||
notificationProfiles: typeof notificationProfiles;
|
||||
pinnedMessages: typeof pinnedMessages;
|
||||
safetyNumber: typeof safetyNumber;
|
||||
search: typeof search;
|
||||
stickers: typeof stickers;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AciString } from '@signalapp/mock-server/src/types.js';
|
||||
import type { MessageAttributesType } from '../model-types.js';
|
||||
import type { ConversationType } from '../state/ducks/conversations.preload.js';
|
||||
import type { ReadonlyMessageAttributesType } from '../model-types.js';
|
||||
|
||||
export type PinnedMessageId = number & { PinnedMessageId: never };
|
||||
|
||||
@@ -19,8 +18,7 @@ export type PinnedMessageParams = Omit<PinnedMessage, 'id'>;
|
||||
|
||||
export type PinnedMessageRenderData = Readonly<{
|
||||
pinnedMessage: PinnedMessage;
|
||||
sender: ConversationType;
|
||||
message: MessageAttributesType;
|
||||
message: ReadonlyMessageAttributesType;
|
||||
}>;
|
||||
|
||||
export type SendPinMessageType = Readonly<{
|
||||
|
||||
Reference in New Issue
Block a user