Refresh StickerManager to use design system components

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
ayumi-signal 2026-06-11 13:13:13 -07:00 committed by GitHub
parent 108241e477
commit 12c7df49f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 464 additions and 303 deletions

View File

@ -3698,6 +3698,14 @@
"messageformat": "No stickers installed",
"description": "Shown in the sticker pack manager when you don't have any installed sticker packs."
},
"icu:stickers--StickerManager--MyStickers--None": {
"messageformat": "No sticker packs, add stickers to send to your friends.",
"description": "Text shown in the sticker pack manager on the installed stickers tab when you don't have any installed sticker packs."
},
"icu:stickers--StickerManager--AddStickers": {
"messageformat": "Add stickers",
"description": "Button in the sticker pack manager on the installed stickers tab when you don't have any installed sticker packs. The button takes you to the All Stickers tab where you can install stickers."
},
"icu:stickers--StickerManager--BlessedPacks": {
"messageformat": "Signal Artist Series",
"description": "Shown in the sticker pack manager above the default sticker packs."
@ -3710,6 +3718,14 @@
"messageformat": "Stickers You Received",
"description": "Shown in the sticker pack manager above sticker packs which you have received in messages."
},
"icu:stickers--StickerManager--ReceivedPacks2": {
"messageformat": "Shared With You",
"description": "Shown in the sticker pack manager above sticker packs which you have received in messages."
},
"icu:stickers--StickerManager--ReceivedPacksDescription": {
"messageformat": "When you receive a sticker from someone, the sticker pack will appear here.",
"description": "Section description shown in the sticker pack manager above sticker packs which you have received in messages."
},
"icu:stickers--StickerManager--ReceivedPacks--Empty": {
"messageformat": "Stickers from incoming messages will appear here",
"description": "Shown in the sticker pack manager when you have not received any sticker packs in messages."
@ -3718,6 +3734,10 @@
"messageformat": "Install",
"description": "Shown in the sticker pack manager next to sticker packs which can be installed."
},
"icu:stickers--StickerManager--Installed": {
"messageformat": "Installed",
"description": "Accessibility label in the sticker pack manager next to sticker packs which are already installed."
},
"icu:stickers--StickerManager--Uninstall": {
"messageformat": "Uninstall",
"description": "Shown in the sticker pack manager next to sticker packs which are already installed."
@ -3726,6 +3746,22 @@
"messageformat": "You may not be able to re-install this sticker pack if you no longer have the source message.",
"description": "Shown in the sticker pack manager next to sticker packs which are already installed."
},
"icu:stickers--StickerManagerHeader--All": {
"messageformat": "All",
"description": "Shown in the sticker pack manager in the navigation tabs. This tab shows all sticker packs available to install."
},
"icu:stickers--StickerManagerHeader--MyStickers": {
"messageformat": "My Stickers",
"description": "Shown in the sticker pack manager in the navigation tabs. This tab shows all sticker packs you have currently installed."
},
"icu:stickers--StickerManagerPackContextMenu--Add": {
"messageformat": "Add",
"description": "Context menu item to add a sticker pack. Shown in the sticker pack manager when right clicking a sticker pack."
},
"icu:stickers--StickerManagerPackContextMenu--Remove": {
"messageformat": "Remove",
"description": "Context menu item to remove a sticker pack. Shown in the sticker pack manager when right clicking a sticker pack."
},
"icu:stickers--StickerPreview--Title": {
"messageformat": "Sticker Pack",
"description": "The title that appears in the sticker pack preview modal."

View File

@ -10,95 +10,13 @@
outline: none;
}
.module-sticker-manager__text {
height: 18px;
letter-spacing: 0px;
line-height: 18px;
padding-inline-start: 8px;
@include mixins.light-theme() {
color: variables.$color-gray-60;
}
@include mixins.dark-theme() {
color: variables.$color-gray-25;
}
&--heading {
@include mixins.font-body-1-bold;
@include mixins.light-theme() {
color: variables.$color-gray-90;
}
@include mixins.dark-theme() {
color: variables.$color-gray-05;
}
}
}
.module-sticker-manager__empty {
display: flex;
justify-content: center;
align-items: center;
height: 64px;
border-radius: 8px;
@include mixins.light-theme {
background: variables.$color-gray-02;
color: variables.$color-gray-60;
}
@include mixins.dark-theme {
background: variables.$color-gray-90;
color: variables.$color-gray-25;
}
}
%blessed-sticker-pack-icon {
height: 14px;
width: 14px;
border-radius: 8px;
background-color: variables.$color-white;
display: inline-block;
vertical-align: middle;
margin-inline-start: 5px;
margin-bottom: 2px;
position: relative;
&::before {
content: '';
display: block;
width: 16px;
height: 16px;
position: absolute;
top: -1px;
inset-inline-start: -1px;
@include mixins.light-theme {
@include mixins.color-svg(
'../images/icons/v3/check/check-circle-fill.svg',
variables.$color-accent-blue
);
}
@include mixins.dark-theme {
@include mixins.color-svg(
'../images/icons/v3/check/check-circle-fill.svg',
variables.$color-accent-blue
);
}
}
}
.module-sticker-manager__pack-row {
@include mixins.button-reset;
& {
display: flex;
flex-direction: row;
padding: 16px;
padding: 10px;
padding-inline-start: 8px;
}
@ -109,13 +27,13 @@
}
&__cover {
width: 48px;
height: 48px;
width: 36px;
height: 36px;
object-fit: contain;
}
&__cover-placeholder {
width: 48px;
height: 48px;
width: 36px;
height: 36px;
background: variables.$color-gray-05;
}
@ -128,26 +46,6 @@
padding-block: 0;
padding-inline: 12px;
}
&__title {
flex: 1;
}
&__author {
flex: 1;
@include mixins.light-theme() {
color: variables.$color-gray-45;
}
@include mixins.dark-theme() {
color: variables.$color-gray-25;
}
}
&__blessed-icon {
@extend %blessed-sticker-pack-icon;
}
}
&__controls {
@ -182,40 +80,3 @@
}
}
}
.module-sticker-manager__install-button {
background: none;
border: 0;
color: variables.$color-gray-90;
@include mixins.font-body-1-bold;
height: 24px;
background: variables.$color-gray-05;
border-radius: 12px;
display: flex;
justify-content: center;
align-items: center;
padding-block: 0;
padding-inline: 12px;
@include mixins.dark-theme {
color: variables.$color-gray-05;
background: variables.$color-gray-75;
}
@include mixins.mouse-mode {
outline: none;
}
&--blue {
@include mixins.light-theme {
background: variables.$color-ultramarine;
color: variables.$color-white;
}
@include mixins.dark-theme {
background: variables.$color-ultramarine-light;
color: variables.$color-white;
}
}
}

View File

@ -12,11 +12,18 @@ import {
sticker1,
sticker2,
} from '../../test-helpers/stickersMocks.std.ts';
import type { StickerPackType } from '../../state/ducks/stickers.preload.ts';
const { i18n } = window.SignalContext;
export default {
title: 'Components/Stickers/StickerManager',
argTypes: {
tab: {
options: ['all', 'my-stickers'],
control: { type: 'select' },
},
},
} satisfies Meta<Props>;
const receivedPacks = [
@ -31,11 +38,21 @@ const installedPacks = [
const blessedPacks = [
createPack(
{ id: 'blessed-pack-1', status: 'downloaded', isBlessed: true },
{
id: 'blessed-pack-1',
status: 'downloaded',
isBlessed: true,
author: 'Ann Chovy',
},
sticker1
),
createPack(
{ id: 'blessed-pack-2', status: 'downloaded', isBlessed: true },
{
id: 'blessed-pack-2',
status: 'downloaded',
isBlessed: true,
author: 'Tom Ato',
},
sticker2
),
];
@ -54,36 +71,51 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
installedPacks: overrideProps.installedPacks || [],
knownPacks: overrideProps.knownPacks || [],
receivedPacks: overrideProps.receivedPacks || [],
tab: overrideProps.tab || 'all',
setTab: action('setTab'),
showToast: action('showToast'),
uninstallStickerPack: action('uninstallStickerPack'),
});
export function Full(): JSX.Element {
const props = createProps({ installedPacks, receivedPacks, blessedPacks });
export function Full(args: Props): JSX.Element {
const props = createProps({ blessedPacks, installedPacks, receivedPacks });
return <StickerManager {...props} />;
return <StickerManager {...props} {...args} />;
}
export function InstalledPacks(): JSX.Element {
const props = createProps({ installedPacks });
export function ReceivedPacks(args: Props): JSX.Element {
const props = createProps({ blessedPacks, receivedPacks });
return <StickerManager {...props} />;
return <StickerManager {...props} {...args} />;
}
export function ReceivedPacks(): JSX.Element {
const props = createProps({ receivedPacks });
export function InstalledPacks(args: Props): JSX.Element {
const blessedPacksWithInstalled = [
{ ...blessedPacks[0], status: 'installed' },
blessedPacks[1],
] as Array<StickerPackType>;
const props = createProps({
blessedPacks: blessedPacksWithInstalled,
installedPacks,
receivedPacks: installedPacks,
});
return <StickerManager {...props} />;
return <StickerManager {...props} {...args} />;
}
export function InstalledAndKnownPacks(): JSX.Element {
const props = createProps({ installedPacks, knownPacks });
export function InstalledAndKnownPacks(args: Props): JSX.Element {
const props = createProps({
blessedPacks,
knownPacks,
installedPacks,
receivedPacks: installedPacks,
});
return <StickerManager {...props} />;
return <StickerManager {...props} {...args} />;
}
export function Empty(): JSX.Element {
export function Empty(args: Props): JSX.Element {
const props = createProps();
return <StickerManager {...props} />;
return <StickerManager {...props} {...args} />;
}

View File

@ -8,13 +8,16 @@ import {
useEffect,
useCallback,
useMemo,
type JSX,
} from 'react';
import { StickerManagerPackRow } from './StickerManagerPackRow.dom.tsx';
import { StickerPreviewModal } from './StickerPreviewModal.dom.tsx';
import type { LocalizerType } from '../../types/Util.std.ts';
import type { StickerPackType } from '../../state/ducks/stickers.preload.ts';
import type { ShowToastAction } from '../../state/ducks/toast.preload.ts';
import { Tabs } from '../Tabs.dom.tsx';
import type { StickerManagerTabType } from '../../types/Stickers.preload.ts';
import { tw } from '../../axo/tw.dom.tsx';
import { AxoButton } from '../../axo/AxoButton.dom.tsx';
export type OwnProps = {
readonly blessedPacks: ReadonlyArray<StickerPackType>;
@ -33,6 +36,8 @@ export type OwnProps = {
readonly installedPacks: ReadonlyArray<StickerPackType>;
readonly knownPacks?: ReadonlyArray<StickerPackType>;
readonly receivedPacks: ReadonlyArray<StickerPackType>;
readonly tab: StickerManagerTabType;
readonly setTab: (value: StickerManagerTabType) => void;
readonly showToast: ShowToastAction;
readonly uninstallStickerPack: (
packId: string,
@ -43,11 +48,6 @@ export type OwnProps = {
export type Props = OwnProps;
enum TabViews {
Available = 'Available',
Installed = 'Installed',
}
export const StickerManager = memo(function StickerManagerInner({
blessedPacks,
closeStickerPackPreview,
@ -57,6 +57,8 @@ export const StickerManager = memo(function StickerManagerInner({
installedPacks,
knownPacks,
receivedPacks,
tab,
setTab,
showToast,
uninstallStickerPack,
}: Props) {
@ -90,6 +92,22 @@ export const StickerManager = memo(function StickerManagerInner({
setPackIdToPreview(packId);
}, []);
const renderStickerPackRow = useCallback(
(pack: StickerPackType): JSX.Element => (
<StickerManagerPackRow
key={pack.id}
pack={pack}
i18n={i18n}
onClickPreview={previewPack}
installStickerPack={installStickerPack}
uninstallStickerPack={uninstallStickerPack}
/>
),
[i18n, installStickerPack, previewPack, uninstallStickerPack]
);
const setTabAll = useCallback(() => setTab('all'), [setTab]);
const allPacks = useMemo(() => {
const packsMap = new Map<string, StickerPackType>();
const packsList = [
@ -99,6 +117,10 @@ export const StickerManager = memo(function StickerManagerInner({
...receivedPacks,
];
packsList.forEach(pack => {
if (packsMap.get(pack.id)) {
return;
}
packsMap.set(pack.id, pack);
});
return packsMap;
@ -123,95 +145,83 @@ export const StickerManager = memo(function StickerManagerInner({
/>
) : null}
<div
className="module-sticker-manager"
className={tw('m-auto max-w-152 px-4 py-0 outline-none')}
data-testid="StickerManager"
tabIndex={-1}
ref={focusRef}
>
<Tabs
initialSelectedTab={TabViews.Available}
tabs={[
{
id: TabViews.Available,
label: i18n('icu:stickers--StickerManager--Available'),
},
{
id: TabViews.Installed,
label: i18n('icu:stickers--StickerManager--InstalledPacks'),
},
]}
>
{({ selectedTab }) => (
<>
{selectedTab === TabViews.Available && (
<>
<h2 className="module-sticker-manager__text module-sticker-manager__text--heading">
{i18n('icu:stickers--StickerManager--BlessedPacks')}
</h2>
{blessedPacks.length > 0 ? (
blessedPacks.map(pack => (
<StickerManagerPackRow
key={pack.id}
pack={pack}
i18n={i18n}
onClickPreview={previewPack}
installStickerPack={installStickerPack}
uninstallStickerPack={uninstallStickerPack}
/>
))
) : (
<div className="module-sticker-manager__empty">
{i18n(
'icu:stickers--StickerManager--BlessedPacks--Empty'
)}
</div>
)}
<h2 className="module-sticker-manager__text module-sticker-manager__text--heading">
{i18n('icu:stickers--StickerManager--ReceivedPacks')}
</h2>
{receivedPacks.length > 0 ? (
receivedPacks.map(pack => (
<StickerManagerPackRow
key={pack.id}
pack={pack}
i18n={i18n}
onClickPreview={previewPack}
installStickerPack={installStickerPack}
uninstallStickerPack={uninstallStickerPack}
/>
))
) : (
<div className="module-sticker-manager__empty">
{i18n(
'icu:stickers--StickerManager--ReceivedPacks--Empty'
)}
</div>
)}
</>
{tab === 'all' && (
<>
<h2
className={tw(
'mx-2 my-1 type-body-medium font-semibold text-label-primary select-none'
)}
{selectedTab === TabViews.Installed &&
(installedPacks.length > 0 ? (
installedPacks.map(pack => (
<StickerManagerPackRow
key={pack.id}
pack={pack}
i18n={i18n}
onClickPreview={previewPack}
installStickerPack={installStickerPack}
uninstallStickerPack={uninstallStickerPack}
/>
))
) : (
<div className="module-sticker-manager__empty">
{i18n(
'icu:stickers--StickerManager--InstalledPacks--Empty'
)}
</div>
))}
</>
)}
</Tabs>
>
{i18n('icu:stickers--StickerManager--BlessedPacks')}
</h2>
{blessedPacks.length > 0 ? (
blessedPacks.map(pack => renderStickerPackRow(pack))
) : (
<p
className={tw(
'mx-2 mb-1 type-body-small text-label-secondary select-none'
)}
>
{i18n('icu:stickers--StickerManager--BlessedPacks--Empty')}
</p>
)}
<div className={tw('mb-4')} />
{receivedPacks.length > 0 && (
<>
<h2
className={tw(
'mx-2 mt-2 mb-0.5 type-body-medium font-semibold text-label-primary select-none'
)}
>
{i18n('icu:stickers--StickerManager--ReceivedPacks2')}
</h2>
<p
className={tw(
'mx-2 mb-1 type-body-small text-label-secondary select-none'
)}
>
{i18n(
'icu:stickers--StickerManager--ReceivedPacksDescription'
)}
</p>
{receivedPacks.map(pack => renderStickerPackRow(pack))}
</>
)}
</>
)}
{tab === 'my-stickers' &&
(installedPacks.length > 0 ? (
installedPacks.map(pack => renderStickerPackRow(pack))
) : (
<div
className={tw(
'm-auto grid min-h-screen place-items-center text-center'
)}
>
<div className={tw('max-w-60')}>
<div
className={tw('mb-4 type-body-medium text-label-secondary')}
>
{i18n('icu:stickers--StickerManager--MyStickers--None')}
</div>
<div>
<AxoButton.Root
variant="secondary"
size="md"
onClick={setTabAll}
>
{i18n('icu:stickers--StickerManager--AddStickers')}
</AxoButton.Root>
</div>
</div>
</div>
))}
</div>
</>
);

View File

@ -0,0 +1,100 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useCallback, type JSX } from 'react';
import { tw } from '../../axo/tw.dom.tsx';
import { ExperimentalAxoSegmentedControl } from '../../axo/AxoSegmentedControl.dom.tsx';
import { AxoSelect } from '../../axo/AxoSelect.dom.tsx';
import type { LocalizerType } from '../../types/Util.std.ts';
import type { StickerManagerTabType } from '../../types/Stickers.preload.ts';
// Provided by smart layer
export type Props = Readonly<{
i18n: LocalizerType;
tab: StickerManagerTabType;
setTab: (newTab: StickerManagerTabType) => void;
}>;
const TabValue = {
All: 'all',
MyStickers: 'my-stickers',
} as const satisfies Record<string, StickerManagerTabType>;
export function StickerManagerHeader({
i18n,
tab,
setTab,
}: Props): JSX.Element {
const setSelectedTabWithDefault = useCallback(
(value: string | null) => {
switch (value) {
case 'my-stickers':
setTab(value);
break;
case null:
break;
default:
setTab('all');
break;
}
},
[setTab]
);
return (
<div
className={tw('@container', 'grow', 'flex flex-row justify-center-safe')}
>
<div className={tw('grow')} />
<div className={tw('hidden max-w-60 grow @min-[200px]:block')}>
<ExperimentalAxoSegmentedControl.Root
variant="track"
width="full"
itemWidth="equal"
value={tab}
onValueChange={setSelectedTabWithDefault}
>
<ExperimentalAxoSegmentedControl.Item value={TabValue.All}>
<ExperimentalAxoSegmentedControl.ItemText>
{i18n('icu:stickers--StickerManagerHeader--All')}
</ExperimentalAxoSegmentedControl.ItemText>
</ExperimentalAxoSegmentedControl.Item>
<ExperimentalAxoSegmentedControl.Item value={TabValue.MyStickers}>
<ExperimentalAxoSegmentedControl.ItemText>
{i18n('icu:stickers--StickerManagerHeader--MyStickers')}
</ExperimentalAxoSegmentedControl.ItemText>
</ExperimentalAxoSegmentedControl.Item>
</ExperimentalAxoSegmentedControl.Root>
</div>
<div className={tw('block @min-[200px]:hidden')}>
<AxoSelect.Root value={tab} onValueChange={setSelectedTabWithDefault}>
<AxoSelect.Trigger
variant="floating"
width="fit"
placeholder=""
chevron="always"
/>
<AxoSelect.Content position="dropdown">
<AxoSelect.Item value={TabValue.All}>
<AxoSelect.ItemText>
{i18n('icu:stickers--StickerManagerHeader--All')}
</AxoSelect.ItemText>
</AxoSelect.Item>
<AxoSelect.Item value={TabValue.MyStickers}>
<AxoSelect.ItemText>
{i18n('icu:stickers--StickerManagerHeader--MyStickers')}
</AxoSelect.ItemText>
</AxoSelect.Item>
</AxoSelect.Content>
</AxoSelect.Root>
</div>
<div className={tw('grow')} />
<div className={tw('min-w-4.5')} />
</div>
);
}

View File

@ -7,12 +7,16 @@ import {
useCallback,
type MouseEvent,
type KeyboardEvent,
type ReactNode,
} from 'react';
import type { LocalizerType } from '../../types/Util.std.ts';
import type { StickerPackType } from '../../state/ducks/stickers.preload.ts';
import { Button, ButtonVariant } from '../Button.dom.tsx';
import { UserText } from '../UserText.dom.tsx';
import { AxoConfirmDialog } from '../../axo/AxoConfirmDialog.dom.tsx';
import { AxoIconButton } from '../../axo/AxoIconButton.dom.tsx';
import { tw } from '../../axo/tw.dom.tsx';
import { OfficialChatInlineBadge } from '../conversation/OfficialChatInlineBadge.dom.tsx';
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.tsx';
export type OwnProps = {
readonly i18n: LocalizerType;
@ -47,7 +51,7 @@ export const StickerManagerPackRow = memo(function StickerManagerPackRowInner({
}, [setUninstalling]);
const handleInstall = useCallback(
(e: MouseEvent) => {
(e: Event | MouseEvent) => {
e.stopPropagation();
if (installStickerPack) {
installStickerPack(id, key, { actionSource: 'ui' });
@ -57,7 +61,7 @@ export const StickerManagerPackRow = memo(function StickerManagerPackRowInner({
);
const handleUninstall = useCallback(
(e: MouseEvent) => {
(e: Event) => {
e.stopPropagation();
if (isBlessed && uninstallStickerPack) {
uninstallStickerPack(id, key, { actionSource: 'ui' });
@ -116,55 +120,98 @@ export const StickerManagerPackRow = memo(function StickerManagerPackRowInner({
{i18n('icu:stickers--StickerManager--Uninstall')}
</AxoConfirmDialog.Action>
</AxoConfirmDialog.Root>
<div
tabIndex={0}
// This can't be a button because we have buttons as descendants
role="button"
onKeyDown={handleKeyDown}
onClick={handleClickPreview}
className="module-sticker-manager__pack-row"
data-testid={id}
<PackContextMenu
i18n={i18n}
handleInstall={handleInstall}
handleUninstall={handleUninstall}
isInstalled={pack.status === 'installed'}
>
{pack.cover ? (
<img
src={pack.cover.url}
alt={pack.title}
className="module-sticker-manager__pack-row__cover"
/>
) : (
<div className="module-sticker-manager__pack-row__cover-placeholder" />
)}
<div className="module-sticker-manager__pack-row__meta">
<div className="module-sticker-manager__pack-row__meta__title">
<UserText text={pack.title} />
{pack.isBlessed ? (
<span className="module-sticker-manager__pack-row__meta__blessed-icon" />
) : null}
</div>
<div className="module-sticker-manager__pack-row__meta__author">
{pack.author}
</div>
</div>
<div className="module-sticker-manager__pack-row__controls">
{pack.status === 'installed' ? (
<Button
aria-label={i18n('icu:stickers--StickerManager--Uninstall')}
variant={ButtonVariant.Secondary}
onClick={handleUninstall}
>
{i18n('icu:stickers--StickerManager--Uninstall')}
</Button>
<div
tabIndex={0}
// This can't be a button because we have buttons as descendants
role="button"
onKeyDown={handleKeyDown}
onClick={handleClickPreview}
className="module-sticker-manager__pack-row"
data-testid={id}
>
{pack.cover ? (
<img
src={pack.cover.url}
alt={pack.title}
className="module-sticker-manager__pack-row__cover"
/>
) : (
<Button
aria-label={i18n('icu:stickers--StickerManager--Install')}
variant={ButtonVariant.Secondary}
onClick={handleInstall}
>
{i18n('icu:stickers--StickerManager--Install')}
</Button>
<div className="module-sticker-manager__pack-row__cover-placeholder" />
)}
<div className="module-sticker-manager__pack-row__meta">
<div
className={tw(
'mb-0.5 flex flex-1 type-body-medium text-label-primary'
)}
>
<UserText text={pack.title} />
{pack.isBlessed ? (
<span className={tw('ms-1')}>
<OfficialChatInlineBadge />
</span>
) : null}
</div>
<div
className={tw('flex flex-1 type-body-small text-label-secondary')}
>
{pack.author}
</div>
</div>
<div className="module-sticker-manager__pack-row__controls">
{pack.status === 'installed' ? (
<AxoIconButton.Root
variant="secondary"
size="md"
symbol="check"
label={i18n('icu:stickers--StickerManager--Installed')}
tooltip={false}
disabled
/>
) : (
<AxoIconButton.Root
variant="secondary"
size="md"
symbol="plus"
label={i18n('icu:stickers--StickerManager--Install')}
onClick={handleInstall}
/>
)}
</div>
</div>
</div>
</PackContextMenu>
</>
);
});
function PackContextMenu(props: {
i18n: LocalizerType;
isInstalled: boolean;
handleInstall: (e: Event) => void;
handleUninstall: (e: Event) => void;
children: ReactNode;
}) {
const { i18n, isInstalled, handleInstall, handleUninstall } = props;
return (
<AxoContextMenu.Root>
<AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
<AxoContextMenu.Content>
{isInstalled ? (
<AxoContextMenu.Item symbol="minus-circle" onSelect={handleUninstall}>
{i18n('icu:stickers--StickerManagerPackContextMenu--Remove')}
</AxoContextMenu.Item>
) : (
<AxoContextMenu.Item symbol="plus-circle" onSelect={handleInstall}>
{i18n('icu:stickers--StickerManagerPackContextMenu--Add')}
</AxoContextMenu.Item>
)}
</AxoContextMenu.Content>
</AxoContextMenu.Root>
);
}

View File

@ -12,6 +12,7 @@ import { DataReader, DataWriter } from '../../sql/Client.preload.ts';
import type {
ActionSourceType,
RecentStickerType,
StickerManagerTabType,
} from '../../types/Stickers.preload.ts';
import {
downloadStickerPack as externalDownloadStickerPack,
@ -42,6 +43,7 @@ export type StickersStateType = ReadonlyDeep<{
packs: Dictionary<StickerPackDBType>;
recentStickers: Array<RecentStickerType>;
blessedPacks: Dictionary<boolean>;
stickerManagerTab: StickerManagerTabType;
}>;
// These are for the React components
@ -138,10 +140,16 @@ type UseStickerFulfilledAction = ReadonlyDeep<{
payload: UseStickerPayloadType;
}>;
type SetStickerManagerTabAction = ReadonlyDeep<{
type: 'stickers/SET_STICKER_MANAGER_TAB';
payload: StickerManagerTabType;
}>;
export type StickersActionType = ReadonlyDeep<
| ClearInstalledStickerPackAction
| InstallStickerPackFulfilledAction
| NoopActionType
| SetStickerManagerTabAction
| StickerAddedAction
| StickerPackAddedAction
| StickerPackRemovedAction
@ -157,6 +165,7 @@ export const actions = {
downloadStickerPack,
installStickerPack,
removeStickerPack,
setStickerManagerTab,
stickerAdded,
stickerPackAdded,
stickerPackUpdated,
@ -381,6 +390,15 @@ async function doUseSticker(
};
}
function setStickerManagerTab(
tab: StickerManagerTabType
): SetStickerManagerTabAction {
return {
type: 'stickers/SET_STICKER_MANAGER_TAB',
payload: tab,
};
}
// Reducer
export function getEmptyState(): StickersStateType {
@ -389,6 +407,7 @@ export function getEmptyState(): StickersStateType {
packs: {},
recentStickers: [],
blessedPacks: {},
stickerManagerTab: 'all',
};
}
@ -557,6 +576,15 @@ export function reducer(
};
}
if (action.type === 'stickers/SET_STICKER_MANAGER_TAB') {
const { payload: stickerManagerTab } = action;
return {
...state,
stickerManagerTab,
};
}
if (action.type === ERASE_STORAGE_SERVICE) {
const { packs } = state;

View File

@ -4,7 +4,10 @@
import lodash, { type Dictionary } from 'lodash';
import { createSelector } from 'reselect';
import type { RecentStickerType } from '../../types/Stickers.preload.ts';
import type {
RecentStickerType,
StickerManagerTabType,
} from '../../types/Stickers.preload.ts';
import {
getLocalAttachmentUrl,
AttachmentDisposition,
@ -156,7 +159,9 @@ export const getReceivedStickerPacks = createSelector(
return filterAndTransformPacks(
packs,
pack =>
(pack.status === 'downloaded' || pack.status === 'pending') &&
(pack.status === 'downloaded' ||
pack.status === 'pending' ||
pack.status === 'installed') &&
!blessedPacks[pack.id],
pack => pack.createdAt,
blessedPacks
@ -173,7 +178,7 @@ export const getBlessedStickerPacks = createSelector(
): Array<StickerPackType> => {
return filterAndTransformPacks(
packs,
pack => blessedPacks[pack.id] != null && pack.status !== 'installed',
pack => blessedPacks[pack.id] != null,
pack => pack.createdAt,
blessedPacks
);
@ -195,3 +200,9 @@ export const getKnownStickerPacks = createSelector(
);
}
);
export const getStickerManagerTab = createSelector(
getStickers,
(stickers: StickersStateType): StickerManagerTabType =>
stickers.stickerManagerTab
);

View File

@ -43,6 +43,7 @@ import { SmartMiniPlayer } from './MiniPlayer.preload.tsx';
import { SmartGroupMemberLabelEditor } from './GroupMemberLabelEditor.preload.tsx';
import { useNavActions } from '../ducks/nav.std.ts';
import { ErrorBoundary } from '../../components/ErrorBoundary.dom.tsx';
import { SmartStickerManagerHeader } from './StickerManagerHeader.preload.tsx';
const log = createLogger('ConversationPanel');
@ -305,6 +306,8 @@ const PanelContainer = forwardRef<HTMLDivElement, PanelPropsType>(
let info: JSX.Element | undefined;
if (panel.type === PanelType.AllMedia) {
info = <SmartAllMediaHeader />;
} else if (panel.type === PanelType.StickerManager) {
info = <SmartStickerManagerHeader />;
} else if (conversationTitle != null) {
info = (
<div className="ConversationPanel__header__info">

View File

@ -10,6 +10,7 @@ import {
getInstalledStickerPacks,
getKnownStickerPacks,
getReceivedStickerPacks,
getStickerManagerTab,
} from '../selectors/stickers.std.ts';
import { useStickersActions } from '../ducks/stickers.preload.ts';
import { useGlobalModalActions } from '../ducks/globalModals.preload.ts';
@ -21,11 +22,13 @@ export const SmartStickerManager = memo(function SmartStickerManager() {
const receivedPacks = useSelector(getReceivedStickerPacks);
const installedPacks = useSelector(getInstalledStickerPacks);
const knownPacks = useSelector(getKnownStickerPacks);
const tab = useSelector(getStickerManagerTab);
const { downloadStickerPack, installStickerPack, uninstallStickerPack } =
useStickersActions();
const { closeStickerPackPreview } = useGlobalModalActions();
const { showToast } = useToastActions();
const { setStickerManagerTab } = useStickersActions();
return (
<StickerManager
@ -39,6 +42,8 @@ export const SmartStickerManager = memo(function SmartStickerManager() {
receivedPacks={receivedPacks}
uninstallStickerPack={uninstallStickerPack}
showToast={showToast}
setTab={setStickerManagerTab}
tab={tab}
/>
);
});

View File

@ -0,0 +1,24 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { memo } from 'react';
import { useSelector } from 'react-redux';
import { StickerManagerHeader } from '../../components/stickers/StickerManagerHeader.dom.tsx';
import { getIntl } from '../selectors/user.std.ts';
import { getStickerManagerTab } from '../selectors/stickers.std.ts';
import { useStickersActions } from '../ducks/stickers.preload.ts';
export const SmartStickerManagerHeader = memo(
function SmartStickerManagerHeader() {
const i18n = useSelector(getIntl);
const tab = useSelector(getStickerManagerTab);
const { setStickerManagerTab } = useStickersActions();
return (
<StickerManagerHeader
i18n={i18n}
tab={tab}
setTab={setStickerManagerTab}
/>
);
}
);

View File

@ -190,8 +190,8 @@ describe('stickers', function (this: Mocha.Suite) {
'[data-testid=StickerManager]'
);
debug('switching to Installed tab');
await stickerManager.locator('.Tabs__tab >> "Installed"').click();
debug('switching to My Stickers tab');
await window.getByText('My Stickers').click();
{
debug('installing first sticker pack via storage service');

View File

@ -101,6 +101,8 @@ export type StickerPackPointerType = Readonly<{
key: string;
}>;
export type StickerManagerTabType = 'all' | 'my-stickers';
export const STICKERPACK_ID_BYTE_LEN = 16;
export const STICKERPACK_KEY_BYTE_LEN = 32;
@ -193,6 +195,7 @@ export async function load(): Promise<void> {
recentStickers,
blessedPacks,
installedPack: null,
stickerManagerTab: 'all',
};
packsToDownload = capturePacksToDownload(packs);

View File

@ -141,6 +141,7 @@ window.testUtilities = {
packs: {},
recentStickers: [],
blessedPacks: {},
stickerManagerTab: 'all',
},
theme: ThemeType.dark,
});