Add UI for more message types in PinnedMessagesBar

This commit is contained in:
Jamie
2025-11-21 07:28:27 -08:00
committed by GitHub
parent 954bb8591b
commit 43a7b02df6
5 changed files with 407 additions and 41 deletions

View File

@@ -1678,6 +1678,62 @@
"messageformat": "See all messages",
"description": "Conversation > With pinned message(s) > Pinned messages bar > More actions menu > See all messages"
},
"icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Address": {
"messageformat": "Address",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Address)"
},
"icu:PinnedMessagesBar__MessagePreview__SymbolLabel--VoiceMessage": {
"messageformat": "Voice message",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Voice message)"
},
"icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Gif": {
"messageformat": "GIF",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (GIF)"
},
"icu:PinnedMessagesBar__MessagePreview__SymbolLabel--File": {
"messageformat": "File",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (File)"
},
"icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Contact": {
"messageformat": "Contact",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Contact)"
},
"icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Payment": {
"messageformat": "Payment",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Payment)"
},
"icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Poll": {
"messageformat": "Poll",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Poll)"
},
"icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Sticker": {
"messageformat": "Sticker",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Sticker)"
},
"icu:PinnedMessagesBar__MessagePreview__Text--Photo": {
"messageformat": "Photo",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Photo)"
},
"icu:PinnedMessagesBar__MessagePreview__Text--Video": {
"messageformat": "Video",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Video)"
},
"icu:PinnedMessagesBar__MessagePreview__Text--VoiceMessage": {
"messageformat": "Voice message",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (Voice message)"
},
"icu:PinnedMessagesBar__MessagePreview__Text--Gif": {
"messageformat": "GIF",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Symbol Accessibility Label (GIF)"
},
"icu:PinnedMessagesBar__MessagePreview__Text--Payment": {
"messageformat": "Payment",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Text (Payment)"
},
"icu:PinnedMessagesBar__MessagePreview__Text--Sticker": {
"messageformat": "Sticker",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Message Preview > Text (Sticker)"
},
"icu:PinnedMessagesPanel__Title": {
"messageformat": "Pinned messages",
"description": "Conversation > Pinned messages panel (view all) > Title"

View File

@@ -68,7 +68,8 @@
// The left pane
&--spoiler-ConversationList,
&--spoiler-SearchResult {
&--spoiler-SearchResult,
&--spoiler-PinnedMessagesBar {
@include mixins.light-theme {
background-color: variables.$color-gray-60;
}

View File

@@ -34,6 +34,7 @@ export enum RenderLocation {
ConversationList = 'ConversationList',
Quote = 'Quote',
MediaEditor = 'MediaEditor',
PinnedMessagesBar = 'PinnedMessagesBar',
SearchResult = 'SearchResult',
StoryViewer = 'StoryViewer',
Timeline = 'Timeline',

View File

@@ -1,11 +1,14 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { Pin, PinId } from './PinnedMessagesBar.dom.js';
import type { Pin, PinMessage } from './PinnedMessagesBar.dom.js';
import { PinnedMessagesBar } from './PinnedMessagesBar.dom.js';
import { tw } from '../../../axo/tw.dom.js';
import type { PinnedMessageId } from '../../../types/PinnedMessage.std.js';
import { BodyRange } from '../../../types/BodyRange.std.js';
const { i18n } = window.SignalContext;
@@ -14,7 +17,7 @@ export default {
} satisfies Meta;
const PIN_1: Pin = {
id: 'pin-1' as PinId,
id: 1 as PinnedMessageId,
sender: {
id: 'conversation-1',
title: 'Jamie',
@@ -22,40 +25,55 @@ const PIN_1: Pin = {
},
message: {
id: 'message-1',
body: 'What should we get for lunch?',
poll: {
question: 'What should we get for lunch?',
},
},
};
const PIN_2: Pin = {
id: 'pin-2' as PinId,
id: 2 as PinnedMessageId,
sender: {
id: 'conversation-1',
id: 'conversation-2',
title: 'Tyler',
isMe: false,
},
message: {
id: 'message-2',
body: 'We found a cute pottery store close to Inokashira Park that were going to check out on Saturday. Anyone want to meet at the south exit at Kichijoji station at 1pm? Too early?',
text: {
body: 'We found a cute pottery store close to Inokashira Park that were going to check out on Saturday. Anyone want to meet at the south exit at Kichijoji station at 1pm? Too early?',
bodyRanges: [
{ start: 11, length: 4, style: BodyRange.Style.ITALIC },
{ start: 39, length: 15, style: BodyRange.Style.SPOILER },
],
},
},
};
const PIN_3: Pin = {
id: 'pin-3' as PinId,
id: 3 as PinnedMessageId,
sender: {
id: 'conversation-1',
id: 'conversation-3',
title: 'Adrian',
isMe: false,
},
message: {
id: 'message-3',
body: 'Photo',
text: {
body: 'Photo',
bodyRanges: [],
},
attachment: {
type: 'photo',
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
},
},
};
function Template(props: { defaultCurrent: PinId; pins: ReadonlyArray<Pin> }) {
function Template(props: {
defaultCurrent: PinnedMessageId;
pins: ReadonlyArray<Pin>;
}) {
const [current, setCurrent] = useState(props.defaultCurrent);
return (
<PinnedMessagesBar
@@ -70,12 +88,95 @@ function Template(props: { defaultCurrent: PinId; pins: ReadonlyArray<Pin> }) {
);
}
export function Default(): JSX.Element {
function Stack(props: { children: ReactNode }) {
return (
<div className={tw('flex max-w-4xl flex-col gap-4 bg-fill-inverted p-4')}>
<Template defaultCurrent={PIN_1.id} pins={[PIN_1]} />
<Template defaultCurrent={PIN_2.id} pins={[PIN_1, PIN_2]} />
<Template defaultCurrent={PIN_3.id} pins={[PIN_1, PIN_2, PIN_3]} />
{props.children}
</div>
);
}
export function Default(): JSX.Element {
return (
<Stack>
<Template defaultCurrent={PIN_1.id} pins={[PIN_1]} />
<Template defaultCurrent={PIN_2.id} pins={[PIN_1, PIN_2]} />
<Template defaultCurrent={PIN_3.id} pins={[PIN_1, PIN_2, PIN_3]} />
</Stack>
);
}
function Variant(props: { title: string; message: Omit<PinMessage, 'id'> }) {
const pin: Pin = {
id: 1 as PinnedMessageId,
sender: {
id: 'conversation-1',
title: props.title,
isMe: true,
},
message: {
id: 'message-1',
...props.message,
},
};
return <Template defaultCurrent={pin.id} pins={[pin]} />;
}
const SHORT_TEXT = 'Lorem, ipsum dolor sit amet';
const IMAGE_URL = '/fixtures/tina-rolf-269345-unsplash.jpg';
export function Variants(): JSX.Element {
return (
<Stack>
<Variant
title="Plain text"
message={{ text: { body: SHORT_TEXT, bodyRanges: [] } }}
/>
<Variant
title="Photo attachment with text"
message={{
text: { body: SHORT_TEXT, bodyRanges: [] },
attachment: { type: 'photo', url: IMAGE_URL },
}}
/>
<Variant
title="Photo attachment"
message={{ attachment: { type: 'photo', url: IMAGE_URL } }}
/>
<Variant
title="Video attachment with text"
message={{
text: { body: SHORT_TEXT, bodyRanges: [] },
attachment: { type: 'video', url: IMAGE_URL },
}}
/>
<Variant
title="Video attachment"
message={{ attachment: { type: 'video', url: IMAGE_URL } }}
/>
<Variant
title="Voice message"
message={{ attachment: { type: 'voiceMessage' } }}
/>
<Variant
title="GIF message"
message={{ attachment: { type: 'gif', url: IMAGE_URL } }}
/>
<Variant
title="File"
message={{ attachment: { type: 'file', name: 'project.zip' } }}
/>
<Variant
title="Poll"
message={{ poll: { question: `${SHORT_TEXT}?` } }}
/>
<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>
);
}

View File

@@ -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 } from 'react';
import React, { 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';
@@ -10,32 +10,58 @@ import { AxoIconButton } from '../../../axo/AxoIconButton.dom.js';
import { AxoDropdownMenu } from '../../../axo/AxoDropdownMenu.dom.js';
import { AriaClickable } from '../../../axo/AriaClickable.dom.js';
import { UserText } from '../../UserText.dom.js';
import type { PinnedMessageId } from '../../../types/PinnedMessage.std.js';
import {
MessageTextRenderer,
RenderLocation,
} from '../MessageTextRenderer.dom.js';
import type { HydratedBodyRangesType } from '../../../types/BodyRange.std.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
import { missingCaseError } from '../../../util/missingCaseError.std.js';
export type PinId = string & { PinId: never };
export type PinMessageText = Readonly<{
body: string;
bodyRanges: HydratedBodyRangesType;
}>;
export type PinMessageAttachment = Readonly<{
type: 'photo' | 'video' | 'voiceMessage' | 'gif' | 'file';
name?: string;
url?: string;
}>;
export type PinMessage = Readonly<{
id: string;
text?: PinMessageText;
attachment?: PinMessageAttachment;
contact?: {
name?: string;
address?: string;
};
payment?: true;
poll?: {
question: string;
};
sticker?: true;
}>;
export type Pin = Readonly<{
id: PinId;
id: PinnedMessageId;
sender: {
id: string;
title: string;
isMe: boolean;
};
message: {
id: string;
body: string;
attachment?: {
url: string;
};
};
message: PinMessage;
}>;
export type PinnedMessagesBarProps = Readonly<{
i18n: LocalizerType;
pins: ReadonlyArray<Pin>;
current: PinId;
onCurrentChange: (current: PinId) => void;
onPinGoTo: (pinId: PinId) => void;
onPinRemove: (pinId: PinId) => void;
current: PinnedMessageId;
onCurrentChange: (current: PinnedMessageId) => void;
onPinGoTo: (pinnedMessageId: PinnedMessageId) => void;
onPinRemove: (pinnedMessageId: PinnedMessageId) => void;
onPinsShowAll: () => void;
}>;
@@ -48,7 +74,7 @@ export const PinnedMessagesBar = memo(function PinnedMessagesBar(
const handleValueChange = useCallback(
(value: string) => {
onCurrentChange(value as PinId);
onCurrentChange(Number(value) as PinnedMessageId);
},
[onCurrentChange]
);
@@ -72,7 +98,7 @@ export const PinnedMessagesBar = memo(function PinnedMessagesBar(
return (
<Tabs.Root
orientation="vertical"
value={props.current}
value={String(props.current)}
onValueChange={handleValueChange}
asChild
activationMode="manual"
@@ -86,7 +112,12 @@ export const PinnedMessagesBar = memo(function PinnedMessagesBar(
/>
{props.pins.map(pin => {
return (
<Tabs.Content key={pin.id} tabIndex={-1} value={pin.id} asChild>
<Tabs.Content
key={pin.id}
tabIndex={-1}
value={String(pin.id)}
asChild
>
<Content
i18n={i18n}
pin={pin}
@@ -133,8 +164,8 @@ function Container(props: {
function TabsList(props: {
i18n: LocalizerType;
pins: ReadonlyArray<Pin>;
current: PinId;
onCurrentChange: (current: PinId) => void;
current: PinnedMessageId;
onCurrentChange: (current: PinnedMessageId) => void;
}) {
const { i18n } = props;
@@ -169,7 +200,7 @@ function TabTrigger(props: {
const { i18n } = props;
return (
<Tabs.Trigger
value={props.pin.id}
value={String(props.pin.id)}
aria-label={i18n('icu:PinnedMessagesBar__Tab__AccessibilityLabel', {
pinNumber: props.pinNumber,
})}
@@ -194,8 +225,8 @@ function TabTrigger(props: {
function Content(props: {
i18n: LocalizerType;
pin: Pin;
onPinGoTo: (pinId: PinId) => void;
onPinRemove: (pinId: PinId) => void;
onPinGoTo: (pinnedMessageId: PinnedMessageId) => void;
onPinRemove: (pinnedMessageId: PinnedMessageId) => void;
onPinsShowAll: () => void;
}) {
const { i18n, pin, onPinGoTo, onPinRemove, onPinsShowAll } = props;
@@ -212,17 +243,19 @@ function Content(props: {
onPinsShowAll();
}, [onPinsShowAll]);
const thumbnailUrl = useMemo(() => {
return getThumbnailUrl(pin.message);
}, [pin.message]);
return (
<div className={tw('flex min-w-0 flex-1 flex-row items-center')}>
{props.pin.message.attachment != null && (
<ImageThumbnail url={props.pin.message.attachment.url} />
)}
{thumbnailUrl != null && <ImageThumbnail url={thumbnailUrl} />}
<div className={tw('min-w-0 flex-1')}>
<h1 className={tw('type-body-small font-semibold text-label-primary')}>
<UserText text={props.pin.sender.title} />
</h1>
<p className={tw('me-2 truncate type-body-medium text-label-primary')}>
<UserText text={props.pin.message.body} />
<MessagePreview i18n={i18n} message={props.pin.message} />
</p>
<AriaClickable.HiddenTrigger
aria-label={i18n(
@@ -266,6 +299,19 @@ function Content(props: {
);
}
function getThumbnailUrl(message: PinMessage): string | null {
if (message.attachment == null) {
return null;
}
if (
message.attachment.type === 'photo' ||
message.attachment.type === 'video'
) {
return message.attachment.url ?? null;
}
return null;
}
function ImageThumbnail(props: { url: string }) {
return (
<img
@@ -275,3 +321,164 @@ function ImageThumbnail(props: { url: string }) {
/>
);
}
type PreviewIcon = Readonly<{
symbol: AxoSymbol.InlineGlyphName;
label: string;
}>;
function getMessagePreviewIcon(
i18n: LocalizerType,
message: PinMessage
): PreviewIcon | null {
if (message.attachment != null) {
if (message.attachment.type === 'voiceMessage') {
return {
symbol: 'audio',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--VoiceMessage'
),
};
}
if (message.attachment.type === 'gif') {
return {
symbol: 'gif',
label: i18n('icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Gif'),
};
}
if (message.attachment.type === 'file') {
return {
symbol: 'file',
label: i18n('icu:PinnedMessagesBar__MessagePreview__SymbolLabel--File'),
};
}
}
if (message.contact?.name != null) {
return {
symbol: 'person-circle',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Contact'
),
};
}
if (message.contact?.address != null) {
return {
symbol: 'location',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Address'
),
};
}
if (message.payment != null) {
return {
symbol: 'creditcard',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Payment'
),
};
}
if (message.poll != null) {
return {
symbol: 'poll',
label: i18n('icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Poll'),
};
}
if (message.sticker != null) {
return {
symbol: 'sticker',
label: i18n(
'icu:PinnedMessagesBar__MessagePreview__SymbolLabel--Sticker'
),
};
}
return null;
}
function getMessagePreviewText(
i18n: LocalizerType,
message: PinMessage
): ReactNode {
if (message.text != null) {
return <MessageTextPreview i18n={i18n} text={message.text} />;
}
if (message.attachment != null) {
if (message.attachment.type === 'photo') {
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Photo');
}
if (message.attachment.type === 'video') {
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Video');
}
if (message.attachment.type === 'voiceMessage') {
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--VoiceMessage');
}
if (message.attachment.type === 'gif') {
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Gif');
}
if (message.attachment.type === 'file') {
return <UserText text={message.attachment.name ?? ''} />;
}
throw missingCaseError(message.attachment.type);
}
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');
}
if (message.poll != null) {
return <UserText text={message.poll.question} />;
}
if (message.sticker != null) {
return i18n('icu:PinnedMessagesBar__MessagePreview__Text--Sticker');
}
return null;
}
function MessagePreview(props: { i18n: LocalizerType; message: PinMessage }) {
const { i18n, message } = props;
const icon = useMemo(() => {
return getMessagePreviewIcon(i18n, message);
}, [i18n, message]);
const text = useMemo(() => {
return getMessagePreviewText(i18n, message);
}, [i18n, message]);
return (
<>
{icon != null && (
<>
<AxoSymbol.InlineGlyph symbol={icon.symbol} label={null} />{' '}
</>
)}
{text}
</>
);
}
function MessageTextPreview(props: {
i18n: LocalizerType;
text: PinMessageText;
}) {
const { i18n } = props;
return (
<MessageTextRenderer
bodyRanges={props.text.bodyRanges}
direction={undefined}
disableLinks
jumboEmojiSize={null}
i18n={i18n}
isSpoilerExpanded={{}}
messageText={props.text.body}
originalMessageText={props.text.body}
onExpandSpoiler={undefined}
onMentionTrigger={() => null}
renderLocation={RenderLocation.PinnedMessagesBar}
textLength={props.text.body.length}
/>
);
}