From 576c1b458cfed4d41136d2cbdcc3019558cef3a2 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:34:16 -0500 Subject: [PATCH] Update sticker preview modal and fix usage in sticker manager Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> --- _locales/en/messages.json | 24 +++ ts/components/ToastManager.dom.stories.tsx | 2 + ts/components/ToastManager.dom.tsx | 8 + .../stickers/StickerManager.dom.stories.tsx | 1 + ts/components/stickers/StickerManager.dom.tsx | 45 ++++- .../stickers/StickerManagerPackRow.dom.tsx | 10 +- .../StickerPreviewModal.dom.stories.tsx | 182 ++++++++---------- .../stickers/StickerPreviewModal.dom.tsx | 180 +++++++++++------ ts/state/ducks/stickers.preload.ts | 34 +++- ts/state/smart/ConversationPanel.preload.tsx | 7 +- ts/state/smart/StickerManager.preload.tsx | 3 + .../smart/StickerPreviewModal.preload.tsx | 3 + ts/test-mock/storage/sticker_test.node.ts | 8 +- ts/types/Toast.dom.tsx | 2 + ts/util/Stickers.std.ts | 6 + 15 files changed, 325 insertions(+), 190 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 823039175b..7384dd81bf 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3734,6 +3734,30 @@ "messageformat": "Error opening sticker pack. Check your internet connection and try again.", "description": "The message that appears in the sticker preview modal when there is an error." }, + "icu:stickers--StickerPreview--Install": { + "messageformat": "Add Stickers", + "description": "Primary button to install a sticker pack in the sticker pack preview modal." + }, + "icu:stickers--StickerPreview--Remove": { + "messageformat": "Remove", + "description": "Button to uninstall and remove a sticker pack in the sticker pack preview modal." + }, + "icu:stickers--StickerPreview--Link": { + "messageformat": "Link", + "description": "Button to copy the link URL of a sticker pack in the sticker pack preview modal." + }, + "icu:stickers--StickerPreview--LinkCopied": { + "messageformat": "Link copied", + "description": "Toast text shown after copying the link URL of a sticker pack in the sticker pack preview modal." + }, + "icu:stickers--StickerPreview--StickerCount": { + "messageformat": "{count, plural, one {# sticker} other {# stickers}}", + "description": "Text showing count of stickers in the sticker pack preview modal." + }, + "icu:stickers--StickerPreview--StickerNoEmojiAriaLabel": { + "messageformat": "Sticker without an emoji", + "description": "Accessibility label for sticker without an assigned emoji in the sticker pack preview modal." + }, "icu:FunPicker__Tab--Emojis": { "messageformat": "Emoji", "description": "FunPicker > Tabs > Emojis Tab > Label (emoji plural)" diff --git a/ts/components/ToastManager.dom.stories.tsx b/ts/components/ToastManager.dom.stories.tsx index 73ded7dd20..9f7ff8cd97 100644 --- a/ts/components/ToastManager.dom.stories.tsx +++ b/ts/components/ToastManager.dom.stories.tsx @@ -109,6 +109,8 @@ function getToast(toastType: ToastType): AnyToast { return { toastType: ToastType.CopiedBackupKey }; case ToastType.CopiedCallLink: return { toastType: ToastType.CopiedCallLink }; + case ToastType.CopiedStickerPackLink: + return { toastType: ToastType.CopiedStickerPackLink }; case ToastType.CopiedUsername: return { toastType: ToastType.CopiedUsername }; case ToastType.CopiedUsernameLink: diff --git a/ts/components/ToastManager.dom.tsx b/ts/components/ToastManager.dom.tsx index a3665db484..2d3419a53f 100644 --- a/ts/components/ToastManager.dom.tsx +++ b/ts/components/ToastManager.dom.tsx @@ -334,6 +334,14 @@ function renderToast({ ); } + if (toastType === ToastType.CopiedStickerPackLink) { + return ( + + {i18n('icu:stickers--StickerPreview--LinkCopied')} + + ); + } + if (toastType === ToastType.CopiedUsername) { return ( diff --git a/ts/components/stickers/StickerManager.dom.stories.tsx b/ts/components/stickers/StickerManager.dom.stories.tsx index 29e8c67ebd..94767df8af 100644 --- a/ts/components/stickers/StickerManager.dom.stories.tsx +++ b/ts/components/stickers/StickerManager.dom.stories.tsx @@ -54,6 +54,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ installedPacks: overrideProps.installedPacks || [], knownPacks: overrideProps.knownPacks || [], receivedPacks: overrideProps.receivedPacks || [], + showToast: action('showToast'), uninstallStickerPack: action('uninstallStickerPack'), }); diff --git a/ts/components/stickers/StickerManager.dom.tsx b/ts/components/stickers/StickerManager.dom.tsx index 918e1e474e..9dc632e9c4 100644 --- a/ts/components/stickers/StickerManager.dom.tsx +++ b/ts/components/stickers/StickerManager.dom.tsx @@ -1,11 +1,19 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { memo, createRef, useState, useEffect, useCallback } from 'react'; +import { + memo, + createRef, + useState, + useEffect, + useCallback, + useMemo, +} 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'; export type OwnProps = { @@ -25,6 +33,7 @@ export type OwnProps = { readonly installedPacks: ReadonlyArray; readonly knownPacks?: ReadonlyArray; readonly receivedPacks: ReadonlyArray; + readonly showToast: ShowToastAction; readonly uninstallStickerPack: ( packId: string, packKey: string, @@ -48,12 +57,11 @@ export const StickerManager = memo(function StickerManagerInner({ installedPacks, knownPacks, receivedPacks, + showToast, uninstallStickerPack, }: Props) { const focusRef = createRef(); - const [packToPreview, setPackToPreview] = useState( - null - ); + const [packIdToPreview, setPackIdToPreview] = useState(null); useEffect(() => { if (!knownPacks) { @@ -75,16 +83,34 @@ export const StickerManager = memo(function StickerManagerInner({ }, []); const clearPackToPreview = useCallback(() => { - setPackToPreview(null); - }, [setPackToPreview]); + setPackIdToPreview(null); + }, [setPackIdToPreview]); - const previewPack = useCallback((pack: StickerPackType) => { - setPackToPreview(pack); + const previewPack = useCallback((packId: string) => { + setPackIdToPreview(packId); }, []); + const allPacks = useMemo(() => { + const packsMap = new Map(); + const packsList = [ + ...blessedPacks, + ...installedPacks, + ...(knownPacks ?? []), + ...receivedPacks, + ]; + packsList.forEach(pack => { + packsMap.set(pack.id, pack); + }); + return packsMap; + }, [blessedPacks, installedPacks, knownPacks, receivedPacks]); + + const packToPreview = useMemo(() => { + return packIdToPreview ? allPacks.get(packIdToPreview) : undefined; + }, [allPacks, packIdToPreview]); + return ( <> - {packToPreview ? ( + {packIdToPreview ? ( ) : null}
unknown; + readonly onClickPreview?: (packId: string) => unknown; readonly installStickerPack?: ( packId: string, packKey: string, @@ -81,10 +81,10 @@ export const StickerManagerPackRow = memo(function StickerManagerPackRowInner({ event.stopPropagation(); event.preventDefault(); - onClickPreview(pack); + onClickPreview(id); } }, - [onClickPreview, pack] + [onClickPreview, id] ); const handleClickPreview = useCallback( @@ -93,10 +93,10 @@ export const StickerManagerPackRow = memo(function StickerManagerPackRowInner({ event.stopPropagation(); event.preventDefault(); - onClickPreview(pack); + onClickPreview(id); } }, - [onClickPreview, pack] + [onClickPreview, id] ); return ( diff --git a/ts/components/stickers/StickerPreviewModal.dom.stories.tsx b/ts/components/stickers/StickerPreviewModal.dom.stories.tsx index 814a521455..e589224ced 100644 --- a/ts/components/stickers/StickerPreviewModal.dom.stories.tsx +++ b/ts/components/stickers/StickerPreviewModal.dom.stories.tsx @@ -46,127 +46,97 @@ const tallSticker: StickerType = { packId: 'tall', }; -export function Full(): JSX.Element { - const title = 'Foo'; - const author = 'Foo McBarrington'; +const pack: StickerPackType = { + id: 'foo', + key: 'foo', + lastUsed: Date.now(), + cover: abeSticker, + title: 'Foo', + isBlessed: false, + author: 'Foo McBarrington', + status: 'downloaded' as const, + stickerCount: 101, + stickers: [ + wideSticker, + tallSticker, + ...Array(101) + .fill(0) + .map((_n, id) => ({ ...abeSticker, id })), + ], +}; - const pack: StickerPackType = { - id: 'foo', - key: 'foo', - lastUsed: Date.now(), - cover: abeSticker, - title, - isBlessed: true, - author, - status: 'downloaded' as const, - stickerCount: 101, - stickers: [ - wideSticker, - tallSticker, - ...Array(101) - .fill(0) - .map((_n, id) => ({ ...abeSticker, id })), - ], - }; +const createProps = (overrideProps: Partial = {}): Props => ({ + closeStickerPackPreview: action('closeStickerPackPreview'), + downloadStickerPack: action('downloadStickerPack'), + i18n, + installStickerPack: action('installStickerPack'), + showToast: action('showToast'), + uninstallStickerPack: action('uninstallStickerPack'), + pack: overrideProps.pack ?? pack, +}); - return ( - - ); +export function Pack(): JSX.Element { + const props = createProps(); + return ; } -export function JustFourStickers(): JSX.Element { - const title = 'Foo'; - const author = 'Foo McBarrington'; - - const pack = { - id: 'foo', - key: 'foo', - lastUsed: Date.now(), - cover: abeSticker, - title, +export function OfficialPack(): JSX.Element { + const blessedPack = { + ...pack, isBlessed: true, - author, - status: 'downloaded' as const, - stickerCount: 101, + }; + const props = createProps({ pack: blessedPack }); + return ; +} + +export function SmallPack(): JSX.Element { + const smallPack = { + ...pack, + stickerCount: 4, stickers: [abeSticker, abeSticker, abeSticker, abeSticker], }; + const props = createProps({ pack: smallPack }); + return ; +} - return ( - - ); +export function PackInstalled(): JSX.Element { + const installedPack = { + ...pack, + status: 'installed' as const, + }; + const props = createProps({ pack: installedPack }); + return ; +} + +export function PackInstallPending(): JSX.Element { + const pendingPack = { + ...pack, + status: 'pending' as const, + }; + const props = createProps({ pack: pendingPack }); + return ; } export function InitialDownload(): JSX.Element { - return ( - - ); + const installingPack = { + ...STICKER_PACK_DEFAULTS, + isBlessed: false, + stickers: [], + }; + const props = createProps({ pack: installingPack }); + return ; } export function PackDeleted(): JSX.Element { - return ( - - ); + const props = createProps(); + return ; } export function PackError(): JSX.Element { - return ( - ({ ...abeSticker, id })), - ], - }} - /> - ); + const errorPack = { + ...pack, + status: 'error' as const, + }; + const props = createProps({ pack: errorPack }); + return ; } diff --git a/ts/components/stickers/StickerPreviewModal.dom.tsx b/ts/components/stickers/StickerPreviewModal.dom.tsx index 470df7264c..99c7dccf2d 100644 --- a/ts/components/stickers/StickerPreviewModal.dom.tsx +++ b/ts/components/stickers/StickerPreviewModal.dom.tsx @@ -4,12 +4,18 @@ import { memo, useState, useEffect, useCallback } from 'react'; 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 { UserText } from '../UserText.dom.tsx'; import { AxoConfirmDialog } from '../../axo/AxoConfirmDialog.dom.tsx'; import { AxoDialog } from '../../axo/AxoDialog.dom.tsx'; import { tw } from '../../axo/tw.dom.tsx'; -import { AxoSymbol } from '../../axo/AxoSymbol.dom.tsx'; +import { AxoStackedButton } from '../../axo/AxoStackedButton.dom.tsx'; import { SpinnerV2 } from '../SpinnerV2.dom.tsx'; +import { OfficialChatInlineBadge } from '../conversation/OfficialChatInlineBadge.dom.tsx'; +import { artAddStickersRoute } from '../../util/signalRoutes.std.ts'; +import { drop } from '../../util/drop.std.ts'; +import { ToastType } from '../../types/Toast.dom.tsx'; +import { fromBase64PackKeyToHex } from '../../util/Stickers.std.ts'; export type Props = Readonly<{ onClose?: () => void; @@ -29,11 +35,20 @@ export type Props = Readonly<{ packKey: string, options: { actionSource: 'ui' } ) => void; + showToast: ShowToastAction; pack?: StickerPackType; i18n: LocalizerType; }>; -function renderBody({ pack, i18n }: Pick) { +function renderBody({ + pack, + i18n, + handleCopyLink, + handleStartUninstall, +}: Pick & { + handleCopyLink: () => void; + handleStartUninstall: () => void; +}) { if (pack == null) { return null; } @@ -64,29 +79,94 @@ function renderBody({ pack, i18n }: Pick) { const placeholders = pack.stickerCount - pack.stickers.length; return ( -
- {pack.stickers.map(({ id, url }) => ( +
+ {pack.cover && ( {pack.title} - ))} - {Array.from({ length: placeholders }, (_, index) => { - return ( -
+ + {pack.isBlessed && ( + + + + )} + +
+
{pack.author}
+
+ {i18n('icu:stickers--StickerPreview--StickerCount', { + count: pack.stickerCount, + })} +
+
+ + + {pack.status === 'installed' && ( + - ); - })} + )} + +
+ {pack.stickers.map(({ emoji, id, url }) => ( + { + ))} + {Array.from({ length: placeholders }, (_, index) => { + return ( +
+ ); + })} +
); } +function isInstallFooterVisible( + pack: StickerPackType | undefined +): pack is StickerPackType { + return Boolean( + pack && + pack.status != null && + pack.status !== 'error' && + pack.status !== 'installed' + ); +} + export const StickerPreviewModal = memo(function StickerPreviewModalInner({ closeStickerPackPreview, downloadStickerPack, @@ -94,6 +174,7 @@ export const StickerPreviewModal = memo(function StickerPreviewModalInner({ installStickerPack, onClose, pack, + showToast, uninstallStickerPack, }: Props) { const [confirmingUninstall, setConfirmingUninstall] = useState(false); @@ -133,8 +214,6 @@ export const StickerPreviewModal = memo(function StickerPreviewModalInner({ onClose?.(); }, [closeStickerPackPreview, onClose, pack]); - const isInstalled = Boolean(pack && pack.status === 'installed'); - const handleInstall = useCallback(() => { if (!pack) { return; @@ -148,9 +227,7 @@ export const StickerPreviewModal = memo(function StickerPreviewModalInner({ } else { installStickerPack(pack.id, pack.key, { actionSource: 'ui' }); } - - handleClose(); - }, [downloadStickerPack, installStickerPack, handleClose, pack]); + }, [downloadStickerPack, installStickerPack, pack]); const handleStartUninstall = useCallback(() => { setConfirmingUninstall(true); @@ -164,53 +241,44 @@ export const StickerPreviewModal = memo(function StickerPreviewModalInner({ setConfirmingUninstall(false); }, [uninstallStickerPack, setConfirmingUninstall, pack]); + const handleCopyLink = useCallback(() => { + if (!pack) { + return; + } + + const link = artAddStickersRoute + .toWebUrl({ + packId: pack.id, + packKey: fromBase64PackKeyToHex(pack.key), + }) + .toString(); + drop(window.navigator.clipboard.writeText(link)); + showToast({ toastType: ToastType.CopiedStickerPackLink }); + }, [pack, showToast]); + return ( <> - + {i18n('icu:stickers--StickerPreview--Title')} - {renderBody({ pack, i18n })} + + {renderBody({ pack, i18n, handleCopyLink, handleStartUninstall })} + - {pack != null && pack.status != null && pack.status !== 'error' && ( - -

- - {pack.isBlessed && ( - - {' '} - - - )} -

-

{pack.author}

-
+ {isInstallFooterVisible(pack) && ( + + {i18n('icu:stickers--StickerPreview--Install')} + )} - - {isInstalled ? ( - - {i18n('icu:stickers--StickerManager--Uninstall')} - - ) : ( - - {i18n('icu:stickers--StickerManager--Install')} - - )} -
diff --git a/ts/state/ducks/stickers.preload.ts b/ts/state/ducks/stickers.preload.ts index 3851d6055c..2d686ecea1 100644 --- a/ts/state/ducks/stickers.preload.ts +++ b/ts/state/ducks/stickers.preload.ts @@ -397,14 +397,32 @@ export function reducer( action: Readonly ): StickersStateType { if (action.type === 'stickers/STICKER_PACK_ADDED') { - // ts complains due to `stickers: {}` being overridden by the payload - // but without full confidence that that's the case, `any` and ignore - // oxlint-disable-next-line typescript/no-explicit-any - const { payload } = action as any; - const newPack = { - stickers: {}, - ...payload, - }; + const { payload } = action; + + // When going from an ephemeral (previewed) pack to installed state, + // copy over ephemeral pack props in memory + const oldPack = state.packs[payload.id]; + const isInstallingPack = + oldPack !== undefined && + payload.attemptedStatus === 'installed' && + payload.status === 'pending'; + let newPack: StickerPackDBType; + if (isInstallingPack) { + newPack = { + ...payload, + stickerCount: oldPack.stickerCount, + title: payload.title === '' ? oldPack.title : payload.title, + author: payload.author === '' ? oldPack.author : payload.author, + // TODO: Ephemeral stickers are stored at a different path then downloaded stickers + // so we can't reuse them + stickers: payload.stickers ?? {}, + }; + } else { + newPack = { + ...payload, + stickers: payload.stickers ?? {}, + }; + } return { ...state, diff --git a/ts/state/smart/ConversationPanel.preload.tsx b/ts/state/smart/ConversationPanel.preload.tsx index 654b2e1110..3875c74c3a 100644 --- a/ts/state/smart/ConversationPanel.preload.tsx +++ b/ts/state/smart/ConversationPanel.preload.tsx @@ -42,6 +42,7 @@ import { SmartPinnedMessagesPanel } from './PinnedMessagesPanel.preload.tsx'; 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'; const log = createLogger('ConversationPanel'); @@ -440,7 +441,11 @@ function PanelElement({ } if (panel.type === PanelType.StickerManager) { - return ; + return ( + + + + ); } log.warn(toLogFormat(missingCaseError(panel.type))); diff --git a/ts/state/smart/StickerManager.preload.tsx b/ts/state/smart/StickerManager.preload.tsx index a3e79284e3..ff7dd0d68c 100644 --- a/ts/state/smart/StickerManager.preload.tsx +++ b/ts/state/smart/StickerManager.preload.tsx @@ -13,6 +13,7 @@ import { } from '../selectors/stickers.std.ts'; import { useStickersActions } from '../ducks/stickers.preload.ts'; import { useGlobalModalActions } from '../ducks/globalModals.preload.ts'; +import { useToastActions } from '../ducks/toast.preload.ts'; export const SmartStickerManager = memo(function SmartStickerManager() { const i18n = useSelector(getIntl); @@ -24,6 +25,7 @@ export const SmartStickerManager = memo(function SmartStickerManager() { const { downloadStickerPack, installStickerPack, uninstallStickerPack } = useStickersActions(); const { closeStickerPackPreview } = useGlobalModalActions(); + const { showToast } = useToastActions(); return ( ); }); diff --git a/ts/state/smart/StickerPreviewModal.preload.tsx b/ts/state/smart/StickerPreviewModal.preload.tsx index e651353df7..fbd3478876 100644 --- a/ts/state/smart/StickerPreviewModal.preload.tsx +++ b/ts/state/smart/StickerPreviewModal.preload.tsx @@ -12,6 +12,7 @@ import { } from '../selectors/stickers.std.ts'; import { useStickersActions } from '../ducks/stickers.preload.ts'; import { useGlobalModalActions } from '../ducks/globalModals.preload.ts'; +import { useToastActions } from '../ducks/toast.preload.ts'; export type ExternalProps = { packId: string; @@ -27,6 +28,7 @@ export const SmartStickerPreviewModal = memo(function SmartStickerPreviewModal({ const { downloadStickerPack, installStickerPack, uninstallStickerPack } = useStickersActions(); const { closeStickerPackPreview } = useGlobalModalActions(); + const { showToast } = useToastActions(); const packDb = packs[packId]; const pack = packDb @@ -41,6 +43,7 @@ export const SmartStickerPreviewModal = memo(function SmartStickerPreviewModal({ installStickerPack={installStickerPack} pack={pack} uninstallStickerPack={uninstallStickerPack} + showToast={showToast} /> ); }); diff --git a/ts/test-mock/storage/sticker_test.node.ts b/ts/test-mock/storage/sticker_test.node.ts index f8b24bace6..f16c977275 100644 --- a/ts/test-mock/storage/sticker_test.node.ts +++ b/ts/test-mock/storage/sticker_test.node.ts @@ -80,7 +80,7 @@ describe('stickers', function (this: Mocha.Suite) { .click(); await window .getByRole('dialog', { name: 'Sticker Pack' }) - .getByRole('button', { name: 'Install' }) + .getByRole('button', { name: 'Add Stickers' }) .click(); debug('waiting for sync message'); @@ -118,12 +118,10 @@ describe('stickers', function (this: Mocha.Suite) { debug('uninstalling first sticker pack via UI'); const state = await phone.expectStorageState('initial state'); - await conversationView - .locator(`a:has-text("${STICKER_PACKS[0].id.toString('hex')}")`) - .click(); + // Dialog remains open after install await window .getByRole('dialog', { name: 'Sticker Pack' }) - .getByRole('button', { name: 'Uninstall' }) + .getByRole('button', { name: 'Remove' }) .click(); // Confirm diff --git a/ts/types/Toast.dom.tsx b/ts/types/Toast.dom.tsx index b9602e9d66..8d7d5d93db 100644 --- a/ts/types/Toast.dom.tsx +++ b/ts/types/Toast.dom.tsx @@ -31,6 +31,7 @@ export enum ToastType { ConversationUnarchived = 'ConversationUnarchived', CopiedBackupKey = 'CopiedBackupKey', CopiedCallLink = 'CopiedCallLink', + CopiedStickerPackLink = 'CopiedStickerPackLink', CopiedUsername = 'CopiedUsername', CopiedUsernameLink = 'CopiedUsernameLink', DangerousFileType = 'DangerousFileType', @@ -159,6 +160,7 @@ export type AnyToast = | { toastType: ToastType.ConversationUnarchived } | { toastType: ToastType.CopiedBackupKey } | { toastType: ToastType.CopiedCallLink } + | { toastType: ToastType.CopiedStickerPackLink } | { toastType: ToastType.CopiedUsername } | { toastType: ToastType.CopiedUsernameLink } | { toastType: ToastType.DangerousFileType } diff --git a/ts/util/Stickers.std.ts b/ts/util/Stickers.std.ts index 4d2bda986d..e5d6692361 100644 --- a/ts/util/Stickers.std.ts +++ b/ts/util/Stickers.std.ts @@ -1,6 +1,8 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import * as Bytes from '../Bytes.std.ts'; + const VALID_PACK_ID_REGEXP = /^[0-9a-f]{32}$/i; export function isPackIdValid(packId: unknown): packId is string { @@ -10,3 +12,7 @@ export function isPackIdValid(packId: unknown): packId is string { export function redactPackId(packId: string): string { return `[REDACTED]${packId.slice(-3)}`; } + +export function fromBase64PackKeyToHex(packKey: string): string { + return Bytes.fromBase64(packKey).toHex(); +}