diff --git a/src/app/features/room/RoomInput.test.tsx b/src/app/features/room/RoomInput.test.tsx index 517db52c6b..10186cc3b8 100644 --- a/src/app/features/room/RoomInput.test.tsx +++ b/src/app/features/room/RoomInput.test.tsx @@ -42,6 +42,8 @@ const testState = vi.hoisted(() => ({ handleFiles: undefined as ((files: File[]) => Promise) | undefined, safeUploadFile: vi.fn(), encryptFile: vi.fn(), + accountPersonaSelection: undefined as any, + roomPersonaSelection: undefined as any, editingEvent: undefined as | { getId: () => string; getContent: () => Record } | undefined, @@ -411,9 +413,18 @@ vi.mock('$hooks/useImagePackRooms', () => ({ useImagePackRooms: () => [] })); vi.mock('$hooks/useComposingCheck', () => ({ useComposingCheck: () => () => false })); vi.mock('$hooks/usePerMessageProfile', () => ({ convertPerMessageProfileToBeeperFormat: () => ({}), + resolvePersona: () => undefined, + resolvePersonaProxy: () => undefined, getCurrentlyUsedPerMessageProfileForAccount: async () => undefined, getCurrentlyUsedPerMessageProfileForRoom: async () => undefined, })); +vi.mock('$app/persona/catalog', () => ({ + ProfileCatalog: class { + list = async () => []; + getSelection = async (scope: 'account' | { roomId: string }) => + scope === 'account' ? testState.accountPersonaSelection : testState.roomPersonaSelection; + }, +})); vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); @@ -743,6 +754,8 @@ beforeEach(() => { testState.safeUploadFile.mockReset().mockImplementation(async (file: File) => file); testState.encryptFile.mockReset(); testState.editingEvent = undefined; + testState.accountPersonaSelection = undefined; + testState.roomPersonaSelection = undefined; testState.matrix.sendMessage.mockReset().mockResolvedValue({ event_id: '$event' }); testState.matrix.sendEvent.mockReset().mockResolvedValue({}); testState.cancelDelayedEvent.mockReset(); @@ -785,6 +798,36 @@ describe('RoomInput submit regressions', () => { expect(testState.matrix.sendMessage.mock.calls[1]?.[2]?.body).toBe('retry me'); }); + it('uses the active resolved persona for attachments and stickers', async () => { + testState.accountPersonaSelection = { + persona: { id: 'account', displayname: 'Account', trigger: { prefix: [] } }, + }; + testState.roomPersonaSelection = { + persona: { id: 'expired-room', displayname: 'Expired', trigger: { prefix: [] } }, + validUntil: Date.now() - 1, + }; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Prepare attachment' })); + fireEvent.click(sendButton()); + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + expect( + testState.matrix.sendMessage.mock.calls[0]?.[2]?.['com.beeper.per_message_profile'] + ).toMatchObject({ + id: 'account', + displayname: 'Account', + }); + + fireEvent.click(screen.getByRole('button', { name: 'Select sticker' })); + await waitFor(() => expect(testState.matrix.sendEvent).toHaveBeenCalledOnce()); + expect( + testState.matrix.sendEvent.mock.calls[0]?.[2]?.['com.beeper.per_message_profile'] + ).toMatchObject({ + id: 'account', + displayname: 'Account', + }); + }); + it('keeps attachment retry locked until every sibling send settles', async () => { const delayedSecondSend = deferred<{ event_id: string }>(); let sendNumber = 0; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c22e505b61..d3436d030d 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -143,13 +143,11 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; -import { - convertPerMessageProfileToBeeperFormat, - getCurrentlyUsedPerMessageProfileForAccount, - getCurrentlyUsedPerMessageProfileForRoom, - type PerMessageProfileMsc4461, - setCurrentlyUsedPerMessageProfileIdForRoom, -} from '$hooks/usePerMessageProfile'; +import { setCurrentlyUsedPerMessageProfileIdForRoom } from '$hooks/usePerMessageProfile'; +import type { PerMessageProfileMsc4461 } from '$app/persona'; +import { ProfileCatalog } from '$app/persona/catalog'; +import { projectPersona } from '$app/persona/projection'; +import { resolvePersona } from '$app/persona/selection'; import { Bell, BellSlash, @@ -177,7 +175,6 @@ import { import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; import { ErrorCode } from '../../cs-errorcode'; import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; -import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; import type { IGenericMSC4459, MSC4459ImagePackReference } from '$types/matrix/common'; import { getImagePackReferencesForMxc, @@ -352,10 +349,6 @@ export const RoomInput = forwardRef( () => new PKitCommandMessageHandler(mx, room), [mx, room] ); - const pluralkitProxyMessageHandler = useMemo(() => new PKitProxyMessageHandler(mx), [mx]); - useEffect(() => { - pluralkitProxyMessageHandler.init(); - }, [pluralkitProxyMessageHandler]); const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat'); const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying'); @@ -1108,22 +1101,22 @@ export const RoomInput = forwardRef( const submittedReplyDraft = submission.replyClaim?.snapshot; const submittedSilentReply = submission.replyClaim?.silentReply ?? silentReply; - /** - * the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending. - * This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message, - * and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed. - */ - const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); - const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - const perMessageProfile = roomPerMessageProfile ?? globalPerMessageProfile; + const catalog = new ProfileCatalog(mx); + const [account, roomSelection] = await Promise.all([ + catalog.getSelection('account'), + catalog.getSelection({ roomId }), + ]); + const perMessageProfile = resolvePersona({ + latched: latchedPersona, + room: roomSelection, + account, + now: Date.now(), + }); if (perMessageProfile) { contents.forEach((c) => { - // We intentionally mutate the objects here to avoid unnecessary copying - // mutating should be unproblematic here, since contents isn't a react component, - // or used for rendering c[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] = - convertPerMessageProfileToBeeperFormat(perMessageProfile, false); + projectPersona(perMessageProfile); }); } @@ -1493,7 +1486,6 @@ export const RoomInput = forwardRef( pmpNoFallback, latchedPersona, isPKCommand: (text) => PKitCommandMessageHandler.isPKCommand(text), - pluralkitProxyMessageHandler, imagePacksUsed: imagePacksUsedRef.current, }); @@ -1659,7 +1651,6 @@ export const RoomInput = forwardRef( pkCompatEnable, silentReply, pmpProxyingEnable, - pluralkitProxyMessageHandler, scheduledTime, editingScheduledDelayId, nicknames, @@ -1857,18 +1848,21 @@ export const RoomInput = forwardRef( content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = getImagePackReferencesForMxcWrappedInMap(mxc, mx, ImageUsage.Sticker, room); - /** - * the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending. - * This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message, - * and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed. - */ - const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); - const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - const perMessageProfile = roomPerMessageProfile ?? globalPerMessageProfile; + const catalog = new ProfileCatalog(mx); + const [account, roomSelection] = await Promise.all([ + catalog.getSelection('account'), + catalog.getSelection({ roomId }), + ]); + const perMessageProfile = resolvePersona({ + latched: latchedPersona, + room: roomSelection, + account, + now: Date.now(), + }); if (perMessageProfile) { content[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] = - convertPerMessageProfileToBeeperFormat(perMessageProfile, false); + projectPersona(perMessageProfile); } content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = getImagePackReferencesForMxcWrappedInMap(mxc, mx, ImageUsage.Sticker, room); diff --git a/src/app/features/room/composerMessage.test.ts b/src/app/features/room/composerMessage.test.ts index e41edc0679..aee137f90c 100644 --- a/src/app/features/room/composerMessage.test.ts +++ b/src/app/features/room/composerMessage.test.ts @@ -4,9 +4,7 @@ import { Command, SHRUG } from '$hooks/useCommands'; import type { MatrixClient, Room } from '$types/matrix-sdk'; import { SerializableMap } from '$types/wrapper/SerializableMap'; import type { MSC4459ImagePackReference } from '$types/matrix/common'; -import type { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; -import type * as PerMessageProfileModule from '$hooks/usePerMessageProfile'; -import type { PerMessageProfileMsc4461 } from '$hooks/usePerMessageProfile'; +import type { PerMessageProfileMsc4461 } from '$app/persona'; const { profiles } = vi.hoisted(() => ({ profiles: { @@ -15,13 +13,17 @@ const { profiles } = vi.hoisted(() => ({ }, })); -vi.mock('$hooks/usePerMessageProfile', async (importOriginal) => ({ - ...(await importOriginal()), - getCurrentlyUsedPerMessageProfileForAccount: () => Promise.resolve(profiles.account), - getCurrentlyUsedPerMessageProfileForRoom: () => Promise.resolve(profiles.room), +vi.mock('$app/persona/catalog', () => ({ + ProfileCatalog: class { + list = () => Promise.resolve([profiles.account, profiles.room].filter(Boolean)); + getSelection = (scope: 'account' | { roomId: string }) => { + const persona = scope === 'account' ? profiles.account : profiles.room; + return Promise.resolve(persona ? { persona } : undefined); + }; + }, })); -const { buildOutgoingMessage } = await import('./composerMessage'); +const { buildEditReplacement, buildOutgoingMessage } = await import('./composerMessage'); const ROOM_ID = '!room:example.org'; @@ -36,11 +38,6 @@ const mx = { getRoom: () => room, } as unknown as MatrixClient; -const noProxyHandler = { - getPmpBasedOnMessage: () => Promise.resolve(undefined), - stripProxyFromMessage: () => undefined, -} as unknown as PKitProxyMessageHandler; - const profile = (id: string, displayname: string): PerMessageProfileMsc4461 => ({ id, displayname, @@ -79,7 +76,6 @@ const build = ( pmpNoFallback: false, latchedPersona: undefined, isPKCommand: () => false, - pluralkitProxyMessageHandler: noProxyHandler, imagePacksUsed: new SerializableMap(), ...overrides, }); @@ -184,16 +180,11 @@ describe('buildOutgoingMessage', () => { }); it('strips a pluralkit proxy wrapper and lets its profile win', async () => { - const proxied = profile('proxy', 'Proxied'); - const handler = { - getPmpBasedOnMessage: () => Promise.resolve(proxied), - stripProxyFromMessage: (text: string) => text.replace(/^A:\s*/, ''), - } as unknown as PKitProxyMessageHandler; - profiles.account = profile('global', 'Global'); + const proxied = { ...profile('proxy', 'Proxied'), trigger: { prefix: ['A: '] } }; + profiles.account = proxied; const result = await build('A: hello there', { pmpProxyingEnable: true, - pluralkitProxyMessageHandler: handler, }); if (result.kind !== 'message') throw new Error('expected a message'); // The wrapper must never reach the wire, and the proxy's profile wins. @@ -208,4 +199,24 @@ describe('buildOutgoingMessage', () => { | undefined; expect(previews?.map((preview) => preview.matched_url)).toContain('https://example.com/page'); }); + + it('preserves the original per-message profile when editing', () => { + const originalProfile = { id: 'original', displayname: 'Original' }; + const edited = buildEditReplacement(plainToEditorInput('updated'), { + mx, + room, + roomId: ROOM_ID, + editingEvent: { + getId: () => '$event', + getContent: () => ({ msgtype: 'm.text', body: 'Original: before' }), + } as never, + currentContent: { 'com.beeper.per_message_profile': originalProfile }, + pmpNoFallback: false, + }); + + expect(edited?.['m.new_content']).toMatchObject({ + body: 'Original: updated', + 'com.beeper.per_message_profile': originalProfile, + }); + }); }); diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts index dc66bed015..aaefda3ceb 100644 --- a/src/app/features/room/composerMessage.ts +++ b/src/app/features/room/composerMessage.ts @@ -17,17 +17,15 @@ import { import { sanitizeText } from '$utils/sanitize'; import { getMentionContent } from '$utils/room/relations'; import type { IReplyDraft } from '$state/room/roomInputDrafts'; -import { - convertPerMessageProfileToBeeperFormat, - getCurrentlyUsedPerMessageProfileForAccount, - getCurrentlyUsedPerMessageProfileForRoom, - type PerMessageProfileMsc4461, -} from '$hooks/usePerMessageProfile'; +import { ProfileCatalog } from '$app/persona/catalog'; +import type { PerMessageProfileMsc4461 } from '$app/persona'; +import { convertPerMessageProfileToBeeperFormat } from '$app/persona/projection'; +import { resolvePersonaProxy } from '$app/persona/proxy'; +import { resolvePersona } from '$app/persona/selection'; import * as prefix from '$unstable/prefixes'; import { outgoingMessageTransforms } from './outgoingMessageTransforms'; import { buildReplacementContent } from './buildReplacementContent'; import { Command, SHRUG, TABLEFLIP, UNFLIP } from '$hooks/useCommands'; -import type { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; import type { MSC4459ImagePackReference } from '$types/matrix/common'; import type { SerializableMap } from '$types/wrapper/SerializableMap'; @@ -63,7 +61,6 @@ export interface BuildOutgoingMessageDeps { /** Persona latched by an earlier proxied message in this room. */ latchedPersona: PerMessageProfileMsc4461 | undefined; isPKCommand: (plainText: string) => boolean; - pluralkitProxyMessageHandler: PKitProxyMessageHandler; imagePacksUsed: SerializableMap; } @@ -124,18 +121,20 @@ const applyPerMessageProfileFallback = ( const bodyWithoutFallback = content.body.startsWith(pmpPrefix) ? content.body.slice(pmpPrefix.length) : content.body; - // guard against double-prefixing when the fallback is already present - if (!content.body.startsWith(pmpPrefix)) content.body = pmpPrefix + content.body; const htmlPrefix = `${sanitizeText(profile.displayname)}: `; if (content.formatted_body && !content.formatted_body.startsWith(htmlPrefix)) { content.formatted_body = htmlPrefix + content.formatted_body; } else { // we don't have a formatted body, but the fallback needs one + // set before content.body so we don't double fallback content.format = 'org.matrix.custom.html'; const escapedBody = sanitizeText(bodyWithoutFallback).replaceAll('\n', '
'); content.formatted_body = `${htmlPrefix}${escapedBody}`; } + + // guard against double-prefixing when the fallback is already present + if (!content.body.startsWith(pmpPrefix)) content.body = pmpPrefix + content.body; }; export async function buildOutgoingMessage( @@ -156,7 +155,6 @@ export async function buildOutgoingMessage( pmpNoFallback, latchedPersona, isPKCommand, - pluralkitProxyMessageHandler, imagePacksUsed, } = deps; @@ -224,20 +222,18 @@ export async function buildOutgoingMessage( // PluralKit-style proxy wrappers must be stripped before building `content`, otherwise // the wrapper itself gets sent verbatim. + const catalog = new ProfileCatalog(mx); + const personas = await catalog.list({ migrate: false }); let proxiedPerMessageProfile: PerMessageProfileMsc4461 | undefined; let proxyStripped = false; if (pmpProxyingEnable) { - proxiedPerMessageProfile = await pluralkitProxyMessageHandler.getPmpBasedOnMessage(plainText); - if (proxiedPerMessageProfile) { - // plainText has spoilers stripped, which breaks spoilers carrying a proxy tag, so - // match the proxy against an unsanitized copy instead. - const unsanitizedPlainText = toPlainText( - serializedChildren, - true, - false, - nicknameReplacement - ).trim(); - const stripped = pluralkitProxyMessageHandler.stripProxyFromMessage(unsanitizedPlainText); + const proxy = resolvePersonaProxy( + personas, + toPlainText(serializedChildren, true, false, nicknameReplacement).trim() + ); + if (proxy) { + proxiedPerMessageProfile = proxy.persona; + const stripped = proxy.body; if (stripped !== undefined) { proxyStripped = true; // Re-run the normal pipeline so a proxied message is parsed like any other. @@ -263,12 +259,17 @@ export async function buildOutgoingMessage( content.formatted_body = customHtml; } - const [globalProfile, roomProfile] = await Promise.all([ - getCurrentlyUsedPerMessageProfileForAccount(mx), - getCurrentlyUsedPerMessageProfileForRoom(mx, roomId), + const [account, roomSelection] = await Promise.all([ + catalog.getSelection('account'), + catalog.getSelection({ roomId }), ]); - const perMessageProfile = - proxiedPerMessageProfile ?? latchedPersona ?? roomProfile ?? globalProfile; + const perMessageProfile = resolvePersona({ + proxy: proxiedPerMessageProfile, + latched: latchedPersona, + room: roomSelection, + account, + now: Date.now(), + }); if (perMessageProfile) applyPerMessageProfileFallback(content, perMessageProfile, pmpNoFallback); return { @@ -329,6 +330,7 @@ export function buildEditReplacement( eventId, getMentionContent(Array.from(mentionData.users), mentionData.room), (getLinks(children) ?? []).map((matched_url) => ({ matched_url })), + // An edit belongs to the identity used for the original event, not the currently selected one. currentContent['com.beeper.per_message_profile'] ?? oldContent['com.beeper.per_message_profile'], pmpNoFallback diff --git a/src/app/features/room/persona-picker/PersonaPicker.test.tsx b/src/app/features/room/persona-picker/PersonaPicker.test.tsx index 6051f0bb7e..b147d67ba3 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.test.tsx +++ b/src/app/features/room/persona-picker/PersonaPicker.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { MatrixClient } from 'matrix-js-sdk'; -import type { PerMessageProfileMsc4461 } from '$hooks/usePerMessageProfile'; +import type { PerMessageProfileMsc4461 } from '$app/persona'; import { TemporaryPersonaPicker, PersonaPickerTab } from './PersonaPicker'; const mocked = vi.hoisted(() => ({ @@ -14,12 +14,33 @@ const mocked = vi.hoisted(() => ({ setAccount: vi.fn<(...args: unknown[]) => Promise>(), })); -vi.mock('$hooks/usePerMessageProfile', () => ({ - getAllPerMessageProfiles: mocked.getAll, - getCurrentlyUsedPerMessageProfileForRoom: mocked.getRoom, - getCurrentlyUsedPerMessageProfileForAccount: mocked.getAccount, - setCurrentlyUsedPerMessageProfileIdForRoom: mocked.setRoom, - setCurrentlyUsedPerMessageProfileIdForAccount: mocked.setAccount, +vi.mock('$app/persona/catalog', () => ({ + ProfileCatalog: class { + constructor(private readonly mx: MatrixClient) {} + + list() { + return mocked.getAll(this.mx); + } + + async getSelection(scope: 'account' | { roomId: string }) { + const persona = + scope === 'account' + ? await mocked.getAccount(this.mx) + : await mocked.getRoom(this.mx, scope.roomId); + return persona ? { persona } : undefined; + } + + setSelection( + scope: 'account' | { roomId: string }, + profileId: string | undefined, + validUntil?: number, + reset?: boolean + ) { + return scope === 'account' + ? mocked.setAccount(this.mx, profileId, validUntil, reset) + : mocked.setRoom(this.mx, scope.roomId, profileId, validUntil, reset); + } + }, })); vi.mock('$hooks/useMediaAuthentication.ts', () => ({ useMediaAuthentication: () => false })); // useActiveTheme reaches for window.matchMedia, which jsdom does not provide. diff --git a/src/app/features/room/persona-picker/PersonaPicker.tsx b/src/app/features/room/persona-picker/PersonaPicker.tsx index db23973b64..eda5d3ab84 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.tsx +++ b/src/app/features/room/persona-picker/PersonaPicker.tsx @@ -7,14 +7,8 @@ import { import { ResponsiveMenu } from '$components/ResponsiveMenu'; import { UserAvatar } from '$components/user-avatar/UserAvatar.tsx'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication.ts'; -import { - getCurrentlyUsedPerMessageProfileForRoom, - getAllPerMessageProfiles, - type PerMessageProfileMsc4461, - setCurrentlyUsedPerMessageProfileIdForRoom, - getCurrentlyUsedPerMessageProfileForAccount, - setCurrentlyUsedPerMessageProfileIdForAccount, -} from '$hooks/usePerMessageProfile'; +import type { PerMessageProfileMsc4461 } from '$app/persona'; +import { ProfileCatalog } from '$app/persona/catalog'; import { mxcUrlToHttp } from '$utils/matrix.ts'; import { isMobileOrTablet } from '$utils/platform'; import { nameInitials } from '$utils/common'; @@ -170,7 +164,9 @@ function PersonaPickerMenu({ void syncProfile( roomSelectionRef, () => - roomId ? getCurrentlyUsedPerMessageProfileForRoom(mx, roomId) : Promise.resolve(undefined), + roomId + ? new ProfileCatalog(mx).getSelection({ roomId }).then((selection) => selection?.persona) + : Promise.resolve(undefined), // A latched persona already reflects the user's intent, so don't overwrite it. (profile) => { if (!selectedRoomPersona) setSelectedRoomPersona(profile); @@ -178,7 +174,7 @@ function PersonaPickerMenu({ ); void syncProfile( globalSelectionRef, - () => getCurrentlyUsedPerMessageProfileForAccount(mx), + () => new ProfileCatalog(mx).getSelection('account').then((selection) => selection?.persona), setSelectedGlobalPersona ); @@ -190,7 +186,7 @@ function PersonaPickerMenu({ const fetchProfiles = useCallback(async (mx_: MatrixClient) => { const fetchGeneration = ++profileFetchGenerationRef.current; try { - const fetchedProfiles = await getAllPerMessageProfiles(mx_); + const fetchedProfiles = await new ProfileCatalog(mx_).list(); if (!mountedRef.current || fetchGeneration !== profileFetchGenerationRef.current) { return; } @@ -328,22 +324,12 @@ function PersonaPickerMenu({ setPersona(disabling ? null : profile); try { - if (isGlobal) { - await setCurrentlyUsedPerMessageProfileIdForAccount( - mx, - disabling ? undefined : profile.id, - undefined, - disabling - ); - } else { - await setCurrentlyUsedPerMessageProfileIdForRoom( - mx, - roomId!, - disabling ? undefined : profile.id, - undefined, - disabling - ); - } + await new ProfileCatalog(mx).setSelection( + isGlobal ? 'account' : { roomId: roomId! }, + disabling ? undefined : profile.id, + undefined, + disabling + ); } catch { if (mountedRef.current && selectionGeneration === generationRef.current) { setPersona(previousPersona); diff --git a/src/app/features/settings/Persona/PerMessageProfileEditor.tsx b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx index 0e2fb8ba34..165f91a480 100644 --- a/src/app/features/settings/Persona/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx @@ -374,9 +374,8 @@ export function PerMessageProfileEditor({ setChangingDisplayName(false); setDisableSetDisplayname(false); if (hasIdChange) { - renamePerMessageProfile(mx, profileId, newId).then(() => { - setCurrentId(newId); - }); + await renamePerMessageProfile(mx, profileId, newId); + setCurrentId(newId); } }, [ mx, diff --git a/src/app/hooks/usePerMessageProfile.test.ts b/src/app/hooks/usePerMessageProfile.test.ts index 7b31cace84..3eecb286e5 100644 --- a/src/app/hooks/usePerMessageProfile.test.ts +++ b/src/app/hooks/usePerMessageProfile.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { MatrixClient } from '$types/matrix-sdk'; -import { MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME } from '$unstable/prefixes'; +import { + MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, +} from '$unstable/prefixes'; import { addOrUpdatePerMessageProfile, deletePerMessageProfile, @@ -9,13 +12,22 @@ import { renamePerMessageProfile, type PerMessageProfileMsc4461, } from './usePerMessageProfile'; +import type { PersonaCatalogContent } from '$app/persona/catalog'; +import { projectPersona } from '$app/persona/projection'; +import { resolvePersonaProxy } from '$app/persona/proxy'; +import { resolvePersona } from '$app/persona/selection'; function createMatrixClient(profiles: PerMessageProfileMsc4461[] = []) { const accountData = new Map(); accountData.set(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, { profiles, - }); + } satisfies PersonaCatalogContent); + const setAccountData = vi.fn<(eventType: string, content: unknown) => Promise>( + async (eventType, content) => { + accountData.set(eventType, content); + } + ); const mx = { getAccountData: vi.fn<(eventType: string) => { getContent: () => unknown } | undefined>( (eventType) => { @@ -23,17 +35,13 @@ function createMatrixClient(profiles: PerMessageProfileMsc4461[] = []) { return content === undefined ? undefined : { getContent: () => content }; } ), - setAccountData: vi.fn<(eventType: string, content: unknown) => Promise>( - async (eventType, content) => { - accountData.set(eventType, content); - } - ), + setAccountData, deleteAccountData: vi.fn<(eventType: string) => Promise>(async (eventType) => { accountData.delete(eventType); }), } as unknown as MatrixClient; - return { accountData, mx }; + return { accountData, mx, setAccountData }; } const profile = (id: string): PerMessageProfileMsc4461 => ({ @@ -43,6 +51,98 @@ const profile = (id: string): PerMessageProfileMsc4461 => ({ }); describe('profile persistence', () => { + it('normalizes the previously nested MSC4461 account-data payload', async () => { + const { accountData, mx } = createMatrixClient(); + accountData.set(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, { + type: 'm.per_message_profiles', + content: { profiles: [profile('first')] }, + }); + + await expect(getAllPerMessageProfiles(mx)).resolves.toEqual([profile('first')]); + expect( + accountData.get(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME) + ).toEqual({ + profiles: [profile('first')], + }); + }); + + it('migrates legacy profile records into the MSC4461 catalog', async () => { + const { accountData, mx } = createMatrixClient(); + accountData.delete(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME); + accountData.set(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.index`, { + profileIds: ['legacy'], + }); + accountData.set(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.legacy`, { + id: 'legacy', + name: 'Legacy', + avatarUrl: 'mxc://example.org/avatar', + }); + + await expect(getAllPerMessageProfiles(mx)).resolves.toEqual([ + { + id: 'legacy', + displayname: 'Legacy', + avatar_url: 'mxc://example.org/avatar', + trigger: { + prefix: [], + 'net.f0rest.suffix': [], + 'net.f0rest.circumfix': [], + }, + }, + ]); + expect( + accountData.get(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.index`) + ).toBeUndefined(); + expect( + accountData.get(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.legacy`) + ).toBeUndefined(); + }); + + it('cleans up an empty legacy index', async () => { + const { accountData, mx } = createMatrixClient(); + accountData.delete(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME); + accountData.set(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.index`, { + profileIds: [], + }); + + await expect(getAllPerMessageProfiles(mx)).resolves.toEqual([]); + expect( + accountData.get(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.index`) + ).toBeUndefined(); + expect( + accountData.get(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME) + ).toEqual({ + profiles: [], + }); + }); + + it('filters malformed catalog and legacy profile entries', async () => { + const { accountData, mx } = createMatrixClient(); + accountData.set(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, { + profiles: [profile('valid'), { id: 'missing-trigger', displayname: 'Invalid' }], + }); + await expect(getAllPerMessageProfiles(mx)).resolves.toEqual([profile('valid')]); + + accountData.delete(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME); + accountData.set(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.index`, { + profileIds: ['valid', 'invalid', 1], + }); + accountData.set(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.valid`, { + id: 'valid', + name: 'Valid', + }); + accountData.set(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.invalid`, { + id: 'invalid', + }); + await expect(getAllPerMessageProfiles(mx)).resolves.toEqual([ + { + id: 'valid', + displayname: 'Valid', + trigger: { prefix: [], 'net.f0rest.suffix': [], 'net.f0rest.circumfix': [] }, + }, + ]); + }); + it('creates and updates profiles', async () => { const original = profile('first'); const { mx } = createMatrixClient([original]); @@ -72,47 +172,37 @@ describe('profile persistence', () => { saved = true; }); - await Promise.resolve(); + await vi.waitFor(() => expect(completeWrite).toBeTypeOf('function')); expect(saved).toBe(false); completeWrite(); await saving; }); - it('reads nested payloads', async () => { - const nestedProfile = profile('nested'); - const { accountData, mx } = createMatrixClient(); - accountData.set(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, { - type: 'm.per_message_profiles', - content: { profiles: [nestedProfile] }, + it('serializes concurrent catalog updates against the latest snapshot', async () => { + const { accountData, mx, setAccountData } = createMatrixClient(); + let releaseFirstWrite!: () => void; + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve; }); - - await expect(getAllPerMessageProfiles(mx)).resolves.toEqual([nestedProfile]); - }); - - it('migrates legacy profiles', async () => { - const { accountData, mx } = createMatrixClient(); - accountData.delete(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME); - accountData.set('fyi.cisnt.permessageprofile.index', { profileIds: ['legacy'] }); - accountData.set('fyi.cisnt.permessageprofile.legacy', { - id: 'legacy', - name: 'Legacy profile', + let writes = 0; + setAccountData.mockImplementation(async (eventType, content) => { + writes += 1; + if (writes === 1) await firstWrite; + accountData.set(eventType, content); }); - const migratedProfile = { - id: 'legacy', - displayname: 'Legacy profile', - trigger: { prefix: [], 'net.f0rest.suffix': [], 'net.f0rest.circumfix': [] }, - }; + const first = addOrUpdatePerMessageProfile(mx, profile('first')); + const second = addOrUpdatePerMessageProfile(mx, profile('second')); - await expect(getAllPerMessageProfiles(mx)).resolves.toEqual([migratedProfile]); - expect( - accountData.get(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME) - ).toEqual({ - profiles: [migratedProfile], - }); - expect(accountData.has('fyi.cisnt.permessageprofile.index')).toBe(false); - expect(accountData.has('fyi.cisnt.permessageprofile.legacy')).toBe(false); + await vi.waitFor(() => expect(writes).toBe(1)); + releaseFirstWrite(); + await Promise.all([first, second]); + + await expect(getAllPerMessageProfiles(mx)).resolves.toEqual([ + profile('first'), + profile('second'), + ]); }); it('deletes profiles', async () => { @@ -133,3 +223,58 @@ describe('profile persistence', () => { ]); }); }); + +describe('persona resolution', () => { + const personas = [ + { ...profile('first'), trigger: { prefix: ['first: '] } }, + { ...profile('second'), trigger: { prefix: ['second: '] } }, + ]; + + it('applies precedence and ignores expired selections', () => { + expect( + resolvePersona({ + proxy: personas[0], + latched: personas[1], + room: { persona: personas[1]! }, + account: { persona: personas[1]! }, + now: 1, + }) + ).toBe(personas[0]); + expect( + resolvePersona({ + room: { persona: personas[0]!, validUntil: 1 }, + account: { persona: personas[1]! }, + now: 1, + }) + ).toBe(personas[1]); + }); + + it('uses the first matching case-sensitive prefix and strips it', () => { + expect(resolvePersonaProxy(personas, 'second: hello')).toEqual({ + persona: personas[1], + body: 'hello', + }); + expect(resolvePersonaProxy(personas, 'Second: hello')).toBeUndefined(); + }); + + it('strips suffix and circumfix triggers', () => { + const suffix = { ...personas[0]!, trigger: { prefix: [], 'net.f0rest.suffix': [' -a'] } }; + const circumfix = { + ...personas[1]!, + trigger: { prefix: [], 'net.f0rest.circumfix': [{ prefix: '[', suffix: ']' }] }, + }; + + expect(resolvePersonaProxy([suffix], 'hello -a')).toEqual({ persona: suffix, body: 'hello' }); + expect(resolvePersonaProxy([circumfix], '[hello]')).toEqual({ + persona: circumfix, + body: 'hello', + }); + }); + + it('projects only message-safe persona fields', () => { + expect(projectPersona(personas[0]!)).toEqual({ + id: 'first', + displayname: 'Profile first', + }); + }); +}); diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 6e61fc37be..74e50ae093 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -1,624 +1,61 @@ -import type { AccountDataCompatVersion } from '$types/matrix/accountData'; - -import type { PronounSet } from '$utils/pronouns'; import type { MatrixClient } from '$types/matrix-sdk'; -import { CustomAccountDataEvent } from '$types/matrix/accountData'; -import type { ColorSet } from './useUserProfile'; import { - MATRIX_UNSTABLE_COLORS, - MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME, - MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME, - MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, - MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME, -} from '$unstable/prefixes'; -import { createKeyedQueue } from '$utils/keyedQueue'; - -const ACCOUNT_DATA_PREFIX = CustomAccountDataEvent.SablePerProfileMessageProfiles; - -/** Account data is read-modify-written, so writes to the same key must not interleave. */ -const enqueueProfilePersistence = createKeyedQueue(); - -/** - * @deprecated in favour if {@link PerMessageProfileMsc4461} - * a per message profile - */ -type PerMessageProfile = { - /** - * a unique id for this profile, can be generated using something like nanoid. - * This is used to identify the profile when applying it to a message, and also used as the key when storing the profile in account data. - */ - id: string; - /** - * the display name to use for messages using this profile. - * This is required because otherwise the profile would have no effect on the message. - */ - name: string; - /** - * the avatar url to use for messages using this profile. - */ - avatarUrl?: string; - /** - * a per message profile can also include pronouns - * @see PronounSet for the format of the pronouns, and how to parse them from a string input - */ - pronouns?: PronounSet[]; - compat?: AccountDataCompatVersion; - colors?: ColorSet; -}; - -export type ProfileTrigger = { - prefix: string[]; - [MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME]?: string[]; - [MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME]?: { - prefix: string; - suffix: string; - }[]; -}; - -/** - * a per message profile - */ -export type PerMessageProfileIndexMsc4461 = { - profiles: PerMessageProfileMsc4461[]; -}; - -/** - * a per message profile - */ -export type PerMessageProfileMsc4461 = { - /** - * a unique id for this profile, can be generated using something like nanoid. - * This is used to identify the profile when applying it to a message, and also used as the key when storing the profile in account data. - */ - id: string; - /** - * the display name to use for messages using this profile. - * This is required because otherwise the profile would have no effect on the message. - */ - displayname: string; - /** - * the avatar url to use for messages using this profile. - */ - avatar_url?: string; - /** - * a per message profile can also include pronouns - * @see PronounSet for the format of the pronouns, and how to parse them from a string input - */ - [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]?: PronounSet[]; - - /** - * following spec MSC4522 - */ - [MATRIX_UNSTABLE_COLORS]?: ColorSet; - - trigger: ProfileTrigger; - - compat?: AccountDataCompatVersion; -}; - -function isPerMessageProfileIndex(content: unknown): content is PerMessageProfileIndexMsc4461 { - return ( - typeof content === 'object' && - content !== null && - 'profiles' in content && - Array.isArray(content.profiles) - ); -} - -function getPerMessageProfileIndex(mx: MatrixClient): PerMessageProfileIndexMsc4461 | undefined { - const content = mx - .getAccountData( - MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME as Parameters< - typeof mx.getAccountData - >[0] - ) - ?.getContent(); - - if (isPerMessageProfileIndex(content)) return content; - if (typeof content !== 'object' || content === null || !('content' in content)) return undefined; - - return isPerMessageProfileIndex(content.content) ? content.content : undefined; -} - -async function savePerMessageProfileIndex(mx: MatrixClient, profiles: PerMessageProfileMsc4461[]) { - await mx.setAccountData( - MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME as Parameters< - typeof mx.setAccountData - >[0], - { profiles } as Parameters[1] - ); -} - -export function convertPmpToMsc4461( - mx: MatrixClient, - profile: PerMessageProfile -): PerMessageProfileMsc4461 { - const triggers: ProfileTrigger = { - prefix: [], - [MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME]: [], - [MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME]: [], - }; - - // lookup old proxyAssociations - getProxyAssociationMap( - mx - .getAccountData( - `${ACCOUNT_DATA_PREFIX}.proxyassociation` as Parameters[0] - ) - ?.getContent() - /* oxlint-disable no-unused-vars */ - ) - .entries() - .filter(([_k, assoc]) => assoc.profileId === profile.id) - .forEach(([k, assoc]) => { - const migratedAssoc = migratePmpProxyAssociation(k, assoc); - if (!migratedAssoc) return; - - if (migratedAssoc.prefix && !migratedAssoc.suffix) { - triggers.prefix.push(migratedAssoc.prefix); - } else if (!migratedAssoc.prefix && migratedAssoc.suffix) { - triggers[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME]!.push( - migratedAssoc.suffix - ); - } else if (migratedAssoc.prefix && migratedAssoc.suffix) { - triggers[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME]!.push({ - prefix: migratedAssoc.prefix, - suffix: migratedAssoc.suffix, - }); - } - }); - - const newPmp: PerMessageProfileMsc4461 = { - id: profile.id, - displayname: profile.name, - avatar_url: profile.avatarUrl, - [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]: profile.pronouns, - [MATRIX_UNSTABLE_COLORS]: profile.colors, - trigger: triggers, - }; - - // delete empty fields - // to-do maybe find a better way of doing it - if (!profile.avatarUrl) delete newPmp.avatar_url; - if (!profile.pronouns || profile.pronouns?.length === 0) - delete newPmp[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]; - if (!profile.colors) delete newPmp[MATRIX_UNSTABLE_COLORS]; - return newPmp; -} - -/** - * the format used by Beeper for per message profiles - * This is the format that Beeper expects when applying a profile to a message before sending it - */ -export type PerMessageProfileBeeperFormat = { - /** - * the unique id for this profile, which is used to identify the profile when applying it to a message, and also used as the key when storing the profile in account data. - */ - id: string; - /** - * the display name to use for messages using this profile. This is required because otherwise the profile would have no effect on the message. - */ - displayname?: string; - /** - * the avatar url to use for messages using this profile. - * Beeper expects this to be a mxc url. - */ - avatar_url?: string; - /** - * using the unstable prefix for pronouns, under which it is also stored in profiles - */ - [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]?: PronounSet[]; - - [MATRIX_UNSTABLE_COLORS]?: ColorSet; - has_fallback?: boolean; -}; - -/** - * converts a per message profile from our format to the format used by Beeper, which is used when applying the profile to a message before sending it. - * We have out own format because we want to have more control over the data and how it's stored in account data. - * @export - * @param {PerMessageProfile} profile the per message profile in our format - * @return {*} {PerMessageProfileBeeperFormat} the per message profile in Beeper's format, which can be applied to a message before sending it - */ -export function convertPerMessageProfileToBeeperFormat( - profile: PerMessageProfileMsc4461, - has_fallback: boolean -): PerMessageProfileBeeperFormat { - const beeperPMP: PerMessageProfileBeeperFormat = { - id: profile.id, - displayname: profile.displayname, - avatar_url: profile.avatar_url, - [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]: - profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME], - [MATRIX_UNSTABLE_COLORS]: profile[MATRIX_UNSTABLE_COLORS], - has_fallback, - }; - // delete empty fields - // to-do maybe find a better way of doing it - if (!profile.displayname || profile?.displayname.trim().length === 0) - delete beeperPMP.displayname; - if (!profile.avatar_url) delete beeperPMP.avatar_url; - if ( - !profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME] || - profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]?.length === 0 - ) - if (!profile[MATRIX_UNSTABLE_COLORS]) delete beeperPMP[MATRIX_UNSTABLE_COLORS]; - if (!has_fallback) delete beeperPMP.has_fallback; - return beeperPMP; -} - -/** - * converts a per message profile from Beeper's format to our format, which is used when storing the profile in account data and using it in the app. - * We have our own format because we want to have more control over the data and how it's stored in account data. - * - * @export - * @param {PerMessageProfileBeeperFormat} beeperProfile the per message profile in Beeper's format - * @return {*} {PerMessageProfile} the per message profile in our format, which can be stored in account data and used in the app - */ -export function convertBeeperFormatToOurPerMessageProfile( - beeperProfile: PerMessageProfileBeeperFormat -): PerMessageProfileMsc4461 { - return { - id: beeperProfile.id, - displayname: beeperProfile.displayname ?? '', - avatar_url: beeperProfile.avatar_url, - [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]: - beeperProfile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME], - [MATRIX_UNSTABLE_COLORS]: beeperProfile[MATRIX_UNSTABLE_COLORS], - trigger: { prefix: [] }, - }; -} - -type PerMessageProfileIndex = { - /** - * a list of all profile ids, used to list all profiles when the user wants to manage them. - */ - profileIds: string[]; - compat: AccountDataCompatVersion; -}; - -/** - * how we will store room associations in the account data :3 - */ -type PerMessageProfileRoomAssociation = { - profileId: string; - validUntil?: number; -}; - -type ProxyVariation = - | { prefix: string; suffix: undefined } - | { prefix: undefined; suffix: string } - | { prefix: string; suffix: string }; - -/** - * Deprecated in favor of {@link PerMessageProfileProxyAssociationV2}, kept for migration purposes - */ -export type PerMessageProfileProxyAssociationV1 = { - profileId: string; - /** - * @deprecated regex (string representation of it) to handle the proxy - */ - regexString: string; - setAt?: number; -}; - -export type PerMessageProfileProxyAssociationV2 = { - /** - * the profile associated with the proxy - */ - profileId: string; - - /** - * optional parameter to save when the proxy was added - */ - setAt?: number; - - prefix: string | undefined; - suffix: string | undefined; -}; - -/** - * associating a profile by proxy - * @author Rye - */ -export type PerMessageProfileProxyAssociation = - | PerMessageProfileProxyAssociationV1 - | PerMessageProfileProxyAssociationV2; - -/** - * @deprecated in favor of {@link PerMessageProfileProxyAssociationV2} - */ -export type InternalPerMessageProfileProxyAssociation = { - /** - * the profile associated with the proxy - */ - profileId: string; - /** - * regex to handle the proxy - */ - regex: RegExp; - /** - * optional parameter to save when the proxy was added - */ - setAt?: number; -}; - -/** - * Used to migrate old format proxy tags to new format. - * @author Josie F0rest - */ -export function extractCircumfixProxyTagsFromKey(proxyId: string): ProxyVariation | null { - const [prefix, suffix] = proxyId.split('text'); - - /* - i tried to do this a smart unpacking way but tsc did not like it. sorry for if-else spam. - feel free to clean this up if you can pass tsc - */ - if (!prefix && !suffix) { - return null; - } else if (prefix && !suffix) { - return { prefix, suffix: undefined }; - } else if (!prefix && suffix) { - return { prefix: undefined, suffix }; - } else { - return { prefix: prefix!, suffix: suffix! }; - } -} - -export function createProxyKey(prefix: string | undefined, suffix: string | undefined) { - return `${prefix || ''}text${suffix || ''}`; -} - -export function proxyNeedsMigration(assoc: PerMessageProfileProxyAssociation) { - return (assoc as PerMessageProfileProxyAssociationV1).regexString !== undefined; -} - -export function migratePmpProxyAssociation( - proxyId: string, - assoc: PerMessageProfileProxyAssociation -): PerMessageProfileProxyAssociationV2 | null { - /* detect old proxy association */ - if ((assoc as PerMessageProfileProxyAssociationV1).regexString) { - const fixes = extractCircumfixProxyTagsFromKey(proxyId); - if (!fixes) return null; - return { - profileId: assoc.profileId, - ...(assoc.setAt && { setAt: assoc.setAt! }), - ...fixes, - }; - } else { - return assoc as PerMessageProfileProxyAssociationV2; - } -} - -/** - * @deprecated in favor of {@link PerMessageProfileProxyAssociationV2} - */ -export function parsePerMessageProfileProxyAssociation( - assoc: PerMessageProfileProxyAssociationV1 -): InternalPerMessageProfileProxyAssociation { - const m = assoc.regexString.match(/^\/([\s\S]*)\/([gimsuy]*)$/); - const source = m?.[1] ?? assoc.regexString; - const flags = m?.[2] ?? ''; - return { - profileId: assoc.profileId, - regex: new RegExp(source, flags), - setAt: assoc.setAt, - } satisfies InternalPerMessageProfileProxyAssociation; -} - -type PerMessageProfileProxyAssociationWrapper = { - /** - * the associations saved in the wrapper - */ - associations: - | Map - | Record; - /** - * optional parameter to save compatibility information - */ - compat?: AccountDataCompatVersion; + convertBeeperFormatToOurPerMessageProfile, + convertPerMessageProfileToBeeperFormat, + projectPersona, + stripPerMessageProfileFormattedBody, + stripPerMessageProfilePlainBody, +} from '$app/persona/projection'; +import { resolvePersonaProxy } from '$app/persona/proxy'; +import { resolvePersona } from '$app/persona/selection'; +import { ProfileCatalog } from '$app/persona/catalog'; +import type { PerMessageProfileMsc4461, Persona } from '$app/persona'; + +// Compatibility exports for existing callers while persona persistence lives in the catalog. +export * from '$app/persona/catalog'; +export { + convertBeeperFormatToOurPerMessageProfile, + convertPerMessageProfileToBeeperFormat, + projectPersona, + resolvePersona, + resolvePersonaProxy, + stripPerMessageProfileFormattedBody, + stripPerMessageProfilePlainBody, }; +export type { + PerMessageProfileBeeperFormat, + PerMessageProfileMsc4461, + Persona, + ProfileTrigger, + ResolvedPersonaSelection, +} from '$app/persona'; -/** - * the shape of the account data for room associations, which is a wrapper around a list of associations. - * This is used to store the associations in account data, and allows us to easily add additional fields in the future if needed without breaking the existing data structure. - */ -type PerMessageProfileRoomAssociationWrapper = { - /** - * Key-Value pairs of room ids and profile ids, used to apply a profile to all messages in a room without having to set the profile for each message individually. - * The key is the room id, and the value is the profile id. The profile id can then be used to fetch the profile data when applying the profile to a message before sending it. - * - * @type {Map} - */ - associations: - | Map - | Record; - compat?: AccountDataCompatVersion; -}; - -/** - * the shape of the account data for room associations, which is a wrapper around a list of associations. - * This is used to store the associations in account data, and allows us to easily add additional fields in the future if needed without breaking the existing data structure. - */ -type PerMessageProfileGlobalAssociationWrapper = { - /** - * Key-Value pairs of room ids and profile ids, used to apply a profile to all messages in a room without having to set the profile for each message individually. - * The key is the room id, and the value is the profile id. The profile id can then be used to fetch the profile data when applying the profile to a message before sending it. - * - * @type {Map} - */ - association: PerMessageProfileRoomAssociation; - compat?: AccountDataCompatVersion; -}; - -/** - * unwrap a profile-room-associations-wrapper - * @param wrapper the wrapper to unwrap - * @returns unwrapped map for profile-room-associations - */ -function getAssociationsMap( - wrapper?: PerMessageProfileRoomAssociationWrapper -): Map { - if (!wrapper?.associations) return new Map(); - if (wrapper.associations instanceof Map) return wrapper.associations; - return new Map(Object.entries(wrapper.associations)); -} - -// Helper to always get a plain object from a Map -function associationsMapToObject( - map: Map -): Record { - return Object.fromEntries(map); -} - -/** - * helper function (similar to getAssociationsMap for Room associations) - * @param wrapper the wrapper to unwrap - * @returns unwrapped map of proxy associations - */ -function getProxyAssociationMap( - wrapper?: PerMessageProfileProxyAssociationWrapper -): Map { - if (!wrapper?.associations) return new Map(); - if (wrapper.associations instanceof Map) return wrapper.associations; - return new Map(Object.entries(wrapper.associations)); -} - -function proxyAssociationsMapToObject( - map: Map -): Record { - return Object.fromEntries(map); -} - -/** - * helper function: getting a profile from the account data where the profile matches a given id - * - * @export - * @param {MatrixClient} mx the matrix client - * @param {string} id the profile id - * @return {*} {(Promise)} the profile, with the profile Id, if it exists - */ export async function getPerMessageProfileById( mx: MatrixClient, id: string -): Promise { - return getPerMessageProfileIndex(mx)?.profiles.find((profile) => profile.id === id); -} - -/** - * @deprecated - * - * getting a profile from the account data where the profile matches a given id - * - * @export - * @param {MatrixClient} mx the matrix client - * @param {string} id the profile id - * @return {*} {(Promise)} the profile, with the profile Id, if it exists - */ -async function getPerMessageProfileByIdDeprecated( - mx: MatrixClient, - id: string -): Promise { - const profile = mx.getAccountData( - `${ACCOUNT_DATA_PREFIX}.${id}` as Parameters[0] - ); - return profile ? (profile.getContent() as unknown as PerMessageProfile) : undefined; +): Promise { + return new ProfileCatalog(mx).get(id); } -async function migrateLegacyPerMessageProfiles( - mx: MatrixClient -): Promise { - const profileIndex = mx.getAccountData( - `${ACCOUNT_DATA_PREFIX}.index` as Parameters[0] - ); - if (!profileIndex) return undefined; - - const profileIds = (profileIndex.getContent() as PerMessageProfileIndex).profileIds ?? []; - const legacyProfiles = await Promise.all( - profileIds.map((id) => getPerMessageProfileByIdDeprecated(mx, id)) - ); - const profiles = legacyProfiles - .filter((profile): profile is PerMessageProfile => profile !== undefined) - .map((profile) => convertPmpToMsc4461(mx, profile)); - - await savePerMessageProfileIndex(mx, profiles); - await Promise.all([ - mx.deleteAccountData( - `${ACCOUNT_DATA_PREFIX}.index` as Parameters[0] - ), - ...legacyProfiles - .filter((profile): profile is PerMessageProfile => profile !== undefined) - .map((profile) => - mx.deleteAccountData( - `${ACCOUNT_DATA_PREFIX}.${profile.id}` as Parameters[0] - ) - ), - ]); - - return profiles; +export async function getAllPerMessageProfiles(mx: MatrixClient): Promise { + return new ProfileCatalog(mx).list(); } -/** - * getting an array of all PerMessageProfile's saved in the account data - * - * @export - * @param {MatrixClient} mx the matrix client - * @return {*} {Promise} a array containing all per-message-profiles saved - */ -export async function getAllPerMessageProfiles( - mx: MatrixClient -): Promise { - const profiles = getPerMessageProfileIndex(mx)?.profiles; - if (profiles) return profiles; - - return (await migrateLegacyPerMessageProfiles(mx)) ?? []; -} - -/** - * add or update a pmp - * @param mx the matrix client - * @param profile the profile to add/update - * @returns void - */ export async function addOrUpdatePerMessageProfile( mx: MatrixClient, profile: PerMessageProfileMsc4461 ) { - const profiles = getPerMessageProfileIndex(mx)?.profiles ?? []; - const existingIndex = profiles.findIndex((existingProfile) => existingProfile.id === profile.id); - const updatedProfiles = - existingIndex === -1 - ? [...profiles, profile] - : profiles.map((existingProfile) => - existingProfile.id === profile.id ? profile : existingProfile - ); + await new ProfileCatalog(mx).upsert(profile); +} - await savePerMessageProfileIndex(mx, updatedProfiles); +export async function deletePerMessageProfile(mx: MatrixClient, id: string) { + await new ProfileCatalog(mx).remove(id); } -async function getRoomsUsingProfile(mx: MatrixClient, profileId: string): Promise { - const accountData = mx.getAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0] - ); - const content: PerMessageProfileRoomAssociationWrapper | undefined = accountData?.getContent(); - const associations = getAssociationsMap(content); - const roomsUsingProfile: string[] = []; - Array.from(associations.entries()).forEach(([roomId, assoc]) => { - if (assoc?.profileId === profileId) roomsUsingProfile.push(roomId); - }); - return roomsUsingProfile; +export async function renamePerMessageProfile(mx: MatrixClient, oldId: string, newId: string) { + await new ProfileCatalog(mx).rename(oldId, newId); } -/** - * sets the per message profile to be used for messages in a room. This is done by setting account data with a list of room associations, which is then checked when sending a message to apply the profile to the message if the room matches an association. The associations can also have an optional expiration time, after which they will be ignored and removed. - * @param mx matrix client - * @param roomId the room id your querying for - * @param profileId the profile id you are querying for - * @param validUntil the timestamp until the pmp association is valid - * @param reset if true, the association for the room will be removed, if false and profileId is undefined, the association will be set to undefined but not removed, meaning it will still be visible in the list of associations but won't have any effect. This is useful for resetting the association without losing the information of which profile was associated before. - * @returns promose that resolves when the association has been set - */ export async function setCurrentlyUsedPerMessageProfileIdForRoom( mx: MatrixClient, roomId: string, @@ -626,199 +63,27 @@ export async function setCurrentlyUsedPerMessageProfileIdForRoom( validUntil?: number, reset?: boolean ) { - return enqueueProfilePersistence('roomassociation', async () => { - const accountData = mx.getAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0] - ); - const content: PerMessageProfileRoomAssociationWrapper | undefined = accountData?.getContent(); - const associations = getAssociationsMap(content); - - if (reset) { - associations.delete(roomId); - await mx.setAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0], - { associations: associationsMapToObject(associations) } as Parameters< - typeof mx.setAccountData - >[1] - ); - return; - } - if (!profileId) { - throw new Error("profile Id is empty, yet it isn't a reset"); - } - associations.set(roomId, { profileId, validUntil }); - await mx.setAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0], - { associations: associationsMapToObject(associations) } as Parameters< - typeof mx.setAccountData - >[1] - ); - }); + return new ProfileCatalog(mx).setSelection({ roomId }, profileId, validUntil, reset); } -/** - * todo - */ export async function setCurrentlyUsedPerMessageProfileIdForAccount( mx: MatrixClient, profileId: string | undefined, validUntil?: number, reset?: boolean ) { - return enqueueProfilePersistence('globalassociation', async () => { - if (reset) { - await mx.deleteAccountData( - `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0] - ); - return; - } - if (!profileId) { - throw new Error("profile Id is empty, yet it isn't a reset"); - } - - const association: PerMessageProfileRoomAssociation = { profileId, validUntil }; - - await mx.setAccountData( - `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0], - { association: association } as Parameters[1] - ); - }); -} - -/* - * @deprecated in favor of Msc4461 format triggers - */ -export async function getAllProxiesForPMP( - mx: MatrixClient, - profileId: string -): Promise { - const cont: PerMessageProfileProxyAssociationWrapper | undefined = mx - .getAccountData( - `${ACCOUNT_DATA_PREFIX}.proxyassociation` as Parameters[0] - ) - ?.getContent(); - if (!cont) return []; - - const pmap = getProxyAssociationMap(cont); - const parr = new Array(); - pmap - .entries() - /* oxlint-disable no-unused-vars */ - .filter(([_k, v]) => v.profileId === profileId) - .forEach(([k, v]) => parr.push(migratePmpProxyAssociation(k, v)!)); - return parr; + return new ProfileCatalog(mx).setSelection('account', profileId, validUntil, reset); } -/** - * - * drops all room associations for a profile, used when deleting a profile to make sure there are no dangling associations left that point to a non existing profile, which could cause issues when trying to apply the profile to a message in a room that still has an association for the deleted profile. - * - * @param {MatrixClient} mx the matrix client - * @param {string} id the id of the profile to drop associations for - */ -async function dropPerMessageProfileRoomAssociations(mx: MatrixClient, id: string) { - const accountData = mx.getAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0] - ); - const content: PerMessageProfileRoomAssociationWrapper | undefined = accountData?.getContent(); - if (!content) return; - const associations = getAssociationsMap(content); - const roomsUsingProfile = await getRoomsUsingProfile(mx, id); - if (roomsUsingProfile.length === 0) return; - roomsUsingProfile.forEach((roomId) => { - associations.delete(roomId); - }); - await mx.setAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0], - { associations: associationsMapToObject(associations) } as Parameters< - typeof mx.setAccountData - >[1] - ); -} - -/** - * deletes a per message profile by its id - * @param mx the matrix client - * @param id the id of the profile to delete - */ -export async function deletePerMessageProfile(mx: MatrixClient, id: string) { - await dropPerMessageProfileRoomAssociations(mx, id); - const profiles = getPerMessageProfileIndex(mx)?.profiles; - if (!profiles) return; - - await savePerMessageProfileIndex( - mx, - profiles.filter((profile) => profile.id !== id) - ); -} - -/** - * move a profile from one id to another, used when renaming a profile to change the id. - * This is done by creating a new profile with the new id and the same data as the old profile, and then deleting the old profile. - * @param mx the matrix client - * @param oldId the id the profile is currently saved under - * @param newId the id it will be moved to - */ -export async function renamePerMessageProfile(mx: MatrixClient, oldId: string, newId: string) { - const profiles = getPerMessageProfileIndex(mx)?.profiles; - if (!profiles?.some((profile) => profile.id === oldId)) { - throw new Error('Profile not found'); - } - - await savePerMessageProfileIndex( - mx, - profiles.map((profile) => (profile.id === oldId ? { ...profile, id: newId } : profile)) - ); -} - -/** - * gets the per message profile to be used for messages in a room - * @param mx matrix client - * @param roomId the room id you are querying for - * @returns the profile to be used - */ export async function getCurrentlyUsedPerMessageProfileForRoom( mx: MatrixClient, roomId: string ): Promise { - const accountData = mx.getAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0] - ); - const content: PerMessageProfileRoomAssociationWrapper | undefined = accountData?.getContent(); - const associations = getAssociationsMap(content); - const profileId = associations.get(roomId)?.profileId; - const pmp = profileId ? await getPerMessageProfileById(mx, profileId) : undefined; - return profileId ? pmp : undefined; + return (await new ProfileCatalog(mx).getSelection({ roomId }))?.persona; } -/** - * get the per message profile associated with the account todo - */ export async function getCurrentlyUsedPerMessageProfileForAccount( mx: MatrixClient ): Promise { - const accountData = mx.getAccountData( - `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0] - ); - const content: PerMessageProfileGlobalAssociationWrapper | undefined = accountData?.getContent(); - const profileId = content?.association.profileId; - const pmp = profileId ? await getPerMessageProfileById(mx, profileId) : undefined; - return profileId ? pmp : undefined; -} - -/* - * If you don't supply a profile, it may fail if the displayname has a colon. - */ -export function stripPerMessageProfilePlainBody( - formatted_body: string, - profile?: PerMessageProfileMsc4461 -): string { - if (profile) { - return formatted_body.replace(`${profile.displayname}: `, ''); - } else { - return formatted_body.replace(/^.*?: /, ''); - } -} -export function stripPerMessageProfileFormattedBody(formatted_body: string): string { - return formatted_body.replace(/^]*>.*?<\/strong>/, ''); + return (await new ProfileCatalog(mx).getSelection('account'))?.persona; } diff --git a/src/app/persona/catalog.test.ts b/src/app/persona/catalog.test.ts new file mode 100644 index 0000000000..77a5b4471a --- /dev/null +++ b/src/app/persona/catalog.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MatrixClient } from '$types/matrix-sdk'; +import { + MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, +} from '$unstable/prefixes'; +import { ProfileCatalog } from './catalog'; + +function createMatrixClient(accountData: Map, writable = false) { + const setAccountData = vi.fn<(eventType: string, content: unknown) => Promise>( + async (eventType, content) => { + if (!writable) throw new Error('offline'); + accountData.set(eventType, content); + } + ); + const deleteAccountData = vi.fn<(eventType: string) => Promise>(async (eventType) => { + if (!writable) throw new Error('offline'); + accountData.delete(eventType); + }); + const mx = { + getAccountData: vi.fn<(eventType: string) => { getContent: () => unknown } | undefined>( + (eventType) => { + const content = accountData.get(eventType); + return content === undefined ? undefined : { getContent: () => content }; + } + ), + setAccountData, + deleteAccountData, + } as unknown as MatrixClient; + return { mx, setAccountData, deleteAccountData }; +} + +describe('ProfileCatalog', () => { + it('filters personas with malformed optional trigger variants', async () => { + const { mx } = createMatrixClient( + new Map([ + [ + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + { + profiles: [ + { id: 'valid', displayname: 'Valid', trigger: { prefix: [] } }, + { + id: 'suffix', + displayname: 'Suffix', + trigger: { prefix: [], 'net.f0rest.suffix': {} }, + }, + { + id: 'circumfix', + displayname: 'Circumfix', + trigger: { prefix: [], 'net.f0rest.circumfix': [{ prefix: '[', suffix: 1 }] }, + }, + ], + }, + ], + ]) + ); + + await expect(new ProfileCatalog(mx).list({ migrate: false })).resolves.toEqual([ + { id: 'valid', displayname: 'Valid', trigger: { prefix: [] } }, + ]); + }); + + it('reads legacy personas without migration writes when requested', async () => { + const accountData = new Map([ + [ + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.index`, + { profileIds: ['legacy'] }, + ], + [ + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.legacy`, + { id: 'legacy', name: 'Legacy' }, + ], + ]); + const { mx } = createMatrixClient(accountData); + + await expect(new ProfileCatalog(mx).list({ migrate: false })).resolves.toMatchObject([ + { id: 'legacy', displayname: 'Legacy' }, + ]); + expect(mx.setAccountData).not.toHaveBeenCalled(); + expect(mx.deleteAccountData).not.toHaveBeenCalled(); + }); + + it('renames selected personas in room and account associations', async () => { + const accountData = new Map([ + [ + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + { profiles: [{ id: 'old', displayname: 'Old', trigger: { prefix: [] } }] }, + ], + [ + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.globalassociation`, + { association: { profileId: 'old', validUntil: 10 } }, + ], + [ + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.roomassociation`, + { associations: { '!room:example.org': { profileId: 'old', validUntil: 20 } } }, + ], + ]); + const { mx } = createMatrixClient(accountData, true); + + await new ProfileCatalog(mx).rename('old', 'new'); + + await expect(new ProfileCatalog(mx).getSelection('account')).resolves.toMatchObject({ + persona: { id: 'new' }, + validUntil: 10, + }); + await expect( + new ProfileCatalog(mx).getSelection({ roomId: '!room:example.org' }) + ).resolves.toMatchObject({ + persona: { id: 'new' }, + validUntil: 20, + }); + }); + + it('removes room and account associations for a deleted persona', async () => { + const accountData = new Map([ + [ + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + { + profiles: [ + { id: 'deleted', displayname: 'Deleted', trigger: { prefix: [] } }, + { id: 'kept', displayname: 'Kept', trigger: { prefix: [] } }, + ], + }, + ], + [ + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.globalassociation`, + { association: { profileId: 'deleted' } }, + ], + [ + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.roomassociation`, + { + associations: { + '!deleted:example.org': { profileId: 'deleted' }, + '!kept:example.org': { profileId: 'kept' }, + }, + }, + ], + ]); + const { mx } = createMatrixClient(accountData, true); + + await new ProfileCatalog(mx).remove('deleted'); + + await expect(new ProfileCatalog(mx).getSelection('account')).resolves.toBeUndefined(); + await expect( + new ProfileCatalog(mx).getSelection({ roomId: '!deleted:example.org' }) + ).resolves.toBeUndefined(); + await expect( + new ProfileCatalog(mx).getSelection({ roomId: '!kept:example.org' }) + ).resolves.toMatchObject({ persona: { id: 'kept' } }); + expect( + accountData.has( + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.globalassociation` + ) + ).toBe(false); + }); + + it('serializes concurrent catalog writes', async () => { + const accountData = new Map(); + const { mx } = createMatrixClient(accountData, true); + const catalog = new ProfileCatalog(mx); + + await Promise.all([ + catalog.upsert({ id: 'first', displayname: 'First', trigger: { prefix: [] } }), + catalog.upsert({ id: 'second', displayname: 'Second', trigger: { prefix: [] } }), + ]); + + await expect(catalog.list()).resolves.toEqual([ + { id: 'first', displayname: 'First', trigger: { prefix: [] } }, + { id: 'second', displayname: 'Second', trigger: { prefix: [] } }, + ]); + }); + + it('migrates legacy records and cleans up their account data', async () => { + const accountData = new Map([ + [ + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.index`, + { profileIds: ['legacy'] }, + ], + [ + `${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.legacy`, + { id: 'legacy', name: 'Legacy' }, + ], + ]); + const { mx } = createMatrixClient(accountData, true); + + await expect(new ProfileCatalog(mx).list()).resolves.toMatchObject([{ id: 'legacy' }]); + expect( + accountData.get(MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME) + ).toMatchObject({ profiles: [{ id: 'legacy' }] }); + expect( + accountData.has(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.index`) + ).toBe(false); + expect( + accountData.has(`${MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME}.legacy`) + ).toBe(false); + }); +}); diff --git a/src/app/persona/catalog.ts b/src/app/persona/catalog.ts new file mode 100644 index 0000000000..29f2fc1956 --- /dev/null +++ b/src/app/persona/catalog.ts @@ -0,0 +1,523 @@ +import type { AccountDataCompatVersion } from '$types/matrix/accountData'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; +import type { ColorSet } from '$hooks/useUserProfile'; +import type { PronounSet } from '$utils/pronouns'; +import { createKeyedQueue } from '$utils/keyedQueue'; +import { + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME, + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME, + MATRIX_UNSTABLE_COLORS, + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME, +} from '$unstable/prefixes'; +import type { + Persona, + PerMessageProfileMsc4461, + ProfileTrigger, + ResolvedPersonaSelection, +} from './index'; + +const ACCOUNT_DATA_PREFIX = CustomAccountDataEvent.SablePerProfileMessageProfiles; +const enqueueProfilePersistence = createKeyedQueue(); + +type LegacyProfile = { + id: string; + name: string; + avatarUrl?: string; + pronouns?: PronounSet[]; + compat?: AccountDataCompatVersion; + colors?: ColorSet; +}; + +type LegacyProfileIndex = { profileIds: string[]; compat: AccountDataCompatVersion }; + +export type PerMessageProfileIndexMsc4461 = { + type: 'm.per_message_profiles'; + content: { profiles: PerMessageProfileMsc4461[] }; +}; + +export type PersonaCatalogContent = { profiles: Persona[] }; +type InvalidPersonaCatalogContent = { + type: 'm.per_message_profiles'; + content: PersonaCatalogContent; +}; + +type ProfileAssociation = { profileId: string; validUntil?: number }; +type RoomAssociationWrapper = { + associations: Map | Record; + compat?: AccountDataCompatVersion; +}; +type GlobalAssociationWrapper = { + association: ProfileAssociation; + compat?: AccountDataCompatVersion; +}; + +type ProxyVariation = + | { prefix: string; suffix: undefined } + | { prefix: undefined; suffix: string } + | { prefix: string; suffix: string }; + +export type PerMessageProfileProxyAssociationV1 = { + profileId: string; + regexString: string; + setAt?: number; +}; +export type PerMessageProfileProxyAssociationV2 = { + profileId: string; + setAt?: number; + prefix: string | undefined; + suffix: string | undefined; +}; +export type PerMessageProfileProxyAssociation = + | PerMessageProfileProxyAssociationV1 + | PerMessageProfileProxyAssociationV2; +export type InternalPerMessageProfileProxyAssociation = { + profileId: string; + regex: RegExp; + setAt?: number; +}; +type ProxyAssociationWrapper = { + associations: + | Map + | Record; + compat?: AccountDataCompatVersion; +}; + +function accountData(mx: MatrixClient, eventType: string) { + return mx.getAccountData(eventType as Parameters[0]); +} + +function isCircumfix(value: unknown): value is { prefix: string; suffix: string } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { prefix?: unknown }).prefix === 'string' && + typeof (value as { suffix?: unknown }).suffix === 'string' + ); +} + +function isPersona(value: unknown): value is Persona { + const persona = value as { + id?: unknown; + displayname?: unknown; + trigger?: Record; + }; + const trigger = persona.trigger; + return ( + typeof value === 'object' && + value !== null && + typeof persona.id === 'string' && + typeof persona.displayname === 'string' && + typeof trigger === 'object' && + trigger !== null && + Array.isArray(trigger.prefix) && + trigger.prefix.every((entry) => typeof entry === 'string') && + (trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME] === undefined || + (Array.isArray(trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME]) && + trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME].every( + (entry) => typeof entry === 'string' + ))) && + (trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME] === undefined || + (Array.isArray(trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME]) && + trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME].every(isCircumfix))) + ); +} + +function isCatalogContent(value: unknown): value is PersonaCatalogContent { + return ( + typeof value === 'object' && + value !== null && + 'profiles' in value && + Array.isArray(value.profiles) + ); +} + +function readCatalog(mx: MatrixClient): { profiles: Persona[]; nested: boolean } | undefined { + const content = accountData( + mx, + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME + )?.getContent(); + if (isCatalogContent(content)) + return { profiles: content.profiles.filter(isPersona), nested: false }; + + const nested = content as InvalidPersonaCatalogContent | undefined; + if (isCatalogContent(nested?.content)) { + return { + profiles: nested.content.profiles.filter(isPersona), + nested: nested.type === 'm.per_message_profiles', + }; + } + return undefined; +} + +async function saveCatalog(mx: MatrixClient, profiles: Persona[]) { + await mx.setAccountData( + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME as Parameters< + typeof mx.setAccountData + >[0], + { profiles } as Parameters[1] + ); +} + +function isLegacyProfile(value: unknown): value is LegacyProfile { + const profile = value as { id?: unknown; name?: unknown }; + return ( + typeof value === 'object' && + value !== null && + typeof profile.id === 'string' && + typeof profile.name === 'string' + ); +} + +function proxyAssociationMap(wrapper?: ProxyAssociationWrapper) { + if (!wrapper?.associations) return new Map(); + return wrapper.associations instanceof Map + ? wrapper.associations + : new Map(Object.entries(wrapper.associations)); +} + +function associationMap(wrapper?: RoomAssociationWrapper) { + if (!wrapper?.associations) return new Map(); + return wrapper.associations instanceof Map + ? wrapper.associations + : new Map(Object.entries(wrapper.associations)); +} + +export function extractCircumfixProxyTagsFromKey(proxyId: string): ProxyVariation | null { + const [prefix, suffix] = proxyId.split('text'); + if (!prefix && !suffix) return null; + if (prefix && !suffix) return { prefix, suffix: undefined }; + if (!prefix && suffix) return { prefix: undefined, suffix }; + return { prefix: prefix!, suffix: suffix! }; +} + +export function createProxyKey(prefix: string | undefined, suffix: string | undefined) { + return `${prefix || ''}text${suffix || ''}`; +} + +export function proxyNeedsMigration(assoc: PerMessageProfileProxyAssociation) { + return (assoc as PerMessageProfileProxyAssociationV1).regexString !== undefined; +} + +export function migratePmpProxyAssociation( + proxyId: string, + assoc: PerMessageProfileProxyAssociation +): PerMessageProfileProxyAssociationV2 | null { + if ((assoc as PerMessageProfileProxyAssociationV1).regexString) { + const fixes = extractCircumfixProxyTagsFromKey(proxyId); + return fixes + ? { profileId: assoc.profileId, ...(assoc.setAt && { setAt: assoc.setAt }), ...fixes } + : null; + } + return assoc as PerMessageProfileProxyAssociationV2; +} + +export function parsePerMessageProfileProxyAssociation( + assoc: PerMessageProfileProxyAssociationV1 +): InternalPerMessageProfileProxyAssociation { + const match = assoc.regexString.match(/^\/([\s\S]*)\/([gimsuy]*)$/); + return { + profileId: assoc.profileId, + regex: new RegExp(match?.[1] ?? assoc.regexString, match?.[2] ?? ''), + setAt: assoc.setAt, + }; +} + +export function convertPmpToMsc4461(mx: MatrixClient, profile: LegacyProfile): Persona { + const trigger: ProfileTrigger = { + prefix: [], + [MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME]: [], + [MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME]: [], + }; + proxyAssociationMap( + accountData(mx, `${ACCOUNT_DATA_PREFIX}.proxyassociation`)?.getContent() as + | ProxyAssociationWrapper + | undefined + ) + .entries() + .filter(([, association]) => association.profileId === profile.id) + .forEach(([key, association]) => { + const migrated = migratePmpProxyAssociation(key, association); + if (!migrated) return; + if (migrated.prefix && !migrated.suffix) trigger.prefix.push(migrated.prefix); + else if (!migrated.prefix && migrated.suffix) + trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME]!.push(migrated.suffix); + else if (migrated.prefix && migrated.suffix) + trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME]!.push({ + prefix: migrated.prefix, + suffix: migrated.suffix, + }); + }); + const persona: Persona = { + id: profile.id, + displayname: profile.name, + avatar_url: profile.avatarUrl, + [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]: profile.pronouns, + [MATRIX_UNSTABLE_COLORS]: profile.colors, + trigger, + }; + if (!profile.avatarUrl) delete persona.avatar_url; + if (!profile.pronouns?.length) delete persona[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]; + if (!profile.colors) delete persona[MATRIX_UNSTABLE_COLORS]; + return persona; +} + +export class ProfileCatalog { + constructor(private readonly mx: MatrixClient) {} + + private async load(migrate: boolean): Promise { + const catalog = readCatalog(this.mx); + if (catalog) { + if (migrate && catalog.nested) await saveCatalog(this.mx, catalog.profiles); + return catalog.profiles; + } + + const index = accountData(this.mx, `${ACCOUNT_DATA_PREFIX}.index`); + if (!index) return []; + const profileIds = (index.getContent() as LegacyProfileIndex | undefined)?.profileIds; + const ids = Array.isArray(profileIds) + ? profileIds.filter((id): id is string => typeof id === 'string') + : []; + const profiles = ids + .map((id) => accountData(this.mx, `${ACCOUNT_DATA_PREFIX}.${id}`)?.getContent()) + .filter(isLegacyProfile) + .map((profile) => convertPmpToMsc4461(this.mx, profile)); + + if (migrate) { + await saveCatalog(this.mx, profiles); + await this.mx.deleteAccountData( + `${ACCOUNT_DATA_PREFIX}.index` as Parameters[0] + ); + await Promise.all( + ids.map((id) => + this.mx.deleteAccountData( + `${ACCOUNT_DATA_PREFIX}.${id}` as Parameters[0] + ) + ) + ); + } + return profiles; + } + + async list({ migrate = true }: { migrate?: boolean } = {}): Promise { + if (!migrate) return this.load(false); + return enqueueProfilePersistence('catalog', () => this.load(true)); + } + + async get(id: string): Promise { + return (await this.list({ migrate: false })).find((persona) => persona.id === id); + } + + async upsert(persona: Persona): Promise { + await enqueueProfilePersistence('catalog', async () => { + const personas = await this.load(true); + const index = personas.findIndex((existing) => existing.id === persona.id); + await saveCatalog( + this.mx, + index === -1 + ? [...personas, persona] + : personas.map((existing) => (existing.id === persona.id ? persona : existing)) + ); + }); + } + + async remove(id: string): Promise { + await enqueueProfilePersistence('catalog', async () => { + await this.dropRoomAssociations(id); + await this.dropGlobalAssociation(id); + await saveCatalog( + this.mx, + (await this.load(true)).filter((persona) => persona.id !== id) + ); + }); + } + + async rename(oldId: string, newId: string): Promise { + await enqueueProfilePersistence('catalog', async () => { + const personas = await this.load(true); + if (!personas.some((persona) => persona.id === oldId)) throw new Error('Profile not found'); + await saveCatalog( + this.mx, + personas.map((persona) => (persona.id === oldId ? { ...persona, id: newId } : persona)) + ); + await this.replaceAssociations(oldId, newId); + }); + } + + async getSelection( + scope: 'account' | { roomId: string } + ): Promise { + const association = + scope === 'account' + ? ( + accountData(this.mx, `${ACCOUNT_DATA_PREFIX}.globalassociation`)?.getContent() as + | GlobalAssociationWrapper + | undefined + )?.association + : associationMap( + accountData(this.mx, `${ACCOUNT_DATA_PREFIX}.roomassociation`)?.getContent() as + | RoomAssociationWrapper + | undefined + ).get(scope.roomId); + if (!association) return undefined; + const persona = await this.get(association.profileId); + return persona ? { persona, validUntil: association.validUntil } : undefined; + } + + async setSelection( + scope: 'account' | { roomId: string }, + profileId: string | undefined, + validUntil?: number, + reset?: boolean + ) { + const key = scope === 'account' ? 'globalassociation' : 'roomassociation'; + return enqueueProfilePersistence(key, async () => { + const eventType = `${ACCOUNT_DATA_PREFIX}.${key}`; + if (reset) { + if (scope === 'account') + await this.mx.deleteAccountData( + eventType as Parameters[0] + ); + else { + const associations = associationMap( + accountData(this.mx, eventType)?.getContent() as RoomAssociationWrapper | undefined + ); + associations.delete(scope.roomId); + await this.mx.setAccountData( + eventType as Parameters[0], + { associations: Object.fromEntries(associations) } as Parameters< + typeof this.mx.setAccountData + >[1] + ); + } + return; + } + if (!profileId) throw new Error("profile Id is empty, yet it isn't a reset"); + if (scope === 'account') { + await this.mx.setAccountData( + eventType as Parameters[0], + { association: { profileId, validUntil } } as Parameters[1] + ); + } else { + const associations = associationMap( + accountData(this.mx, eventType)?.getContent() as RoomAssociationWrapper | undefined + ); + associations.set(scope.roomId, { profileId, validUntil }); + await this.mx.setAccountData( + eventType as Parameters[0], + { associations: Object.fromEntries(associations) } as Parameters< + typeof this.mx.setAccountData + >[1] + ); + } + }); + } + + private async dropRoomAssociations(profileId: string) { + await enqueueProfilePersistence('roomassociation', async () => { + const eventType = `${ACCOUNT_DATA_PREFIX}.roomassociation`; + const content = accountData(this.mx, eventType)?.getContent() as + | RoomAssociationWrapper + | undefined; + if (!content) return; + const associations = associationMap(content); + let changed = false; + for (const [roomId, association] of associations) { + if (association?.profileId === profileId) { + associations.delete(roomId); + changed = true; + } + } + if (changed) { + await this.mx.setAccountData( + eventType as Parameters[0], + { ...content, associations: Object.fromEntries(associations) } as Parameters< + typeof this.mx.setAccountData + >[1] + ); + } + }); + } + + private async replaceAssociations(oldId: string, newId: string) { + await Promise.all([ + this.replaceRoomAssociations(oldId, newId), + this.replaceGlobalAssociation(oldId, newId), + ]); + } + + private async replaceRoomAssociations(oldId: string, newId: string) { + await enqueueProfilePersistence('roomassociation', async () => { + const eventType = `${ACCOUNT_DATA_PREFIX}.roomassociation`; + const content = accountData(this.mx, eventType)?.getContent() as + | RoomAssociationWrapper + | undefined; + if (!content) return; + const associations = associationMap(content); + let changed = false; + for (const association of associations.values()) { + if (association?.profileId === oldId) { + association.profileId = newId; + changed = true; + } + } + if (changed) { + await this.mx.setAccountData( + eventType as Parameters[0], + { ...content, associations: Object.fromEntries(associations) } as Parameters< + typeof this.mx.setAccountData + >[1] + ); + } + }); + } + + private async replaceGlobalAssociation(oldId: string, newId: string) { + await enqueueProfilePersistence('globalassociation', async () => { + const eventType = `${ACCOUNT_DATA_PREFIX}.globalassociation`; + const content = accountData(this.mx, eventType)?.getContent() as + | GlobalAssociationWrapper + | undefined; + if (content?.association?.profileId !== oldId) return; + await this.mx.setAccountData( + eventType as Parameters[0], + { + ...content, + association: { ...content.association, profileId: newId }, + } as Parameters[1] + ); + }); + } + + private async dropGlobalAssociation(profileId: string) { + await enqueueProfilePersistence('globalassociation', async () => { + const eventType = `${ACCOUNT_DATA_PREFIX}.globalassociation`; + const content = accountData(this.mx, eventType)?.getContent() as + | GlobalAssociationWrapper + | undefined; + if (content?.association?.profileId !== profileId) return; + await this.mx.deleteAccountData( + eventType as Parameters[0] + ); + }); + } +} + +export async function getAllProxiesForPMP( + mx: MatrixClient, + profileId: string +): Promise { + return [ + ...proxyAssociationMap( + accountData(mx, `${ACCOUNT_DATA_PREFIX}.proxyassociation`)?.getContent() as + | ProxyAssociationWrapper + | undefined + ).entries(), + ] + .filter(([, association]) => association.profileId === profileId) + .flatMap(([key, association]) => { + const migrated = migratePmpProxyAssociation(key, association); + return migrated ? [migrated] : []; + }); +} diff --git a/src/app/persona/index.ts b/src/app/persona/index.ts new file mode 100644 index 0000000000..07512f28e9 --- /dev/null +++ b/src/app/persona/index.ts @@ -0,0 +1,45 @@ +import type { AccountDataCompatVersion } from '$types/matrix/accountData'; +import type { PronounSet } from '$utils/pronouns'; +import type { + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME, + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME, + MATRIX_UNSTABLE_COLORS, + MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME, +} from '$unstable/prefixes'; +import type { ColorSet } from '$hooks/useUserProfile'; + +export type ProfileTrigger = { + prefix: string[]; + [MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME]?: string[]; + [MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME]?: { + prefix: string; + suffix: string; + }[]; +}; + +export type PerMessageProfileMsc4461 = { + id: string; + displayname: string; + avatar_url?: string; + [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]?: PronounSet[]; + [MATRIX_UNSTABLE_COLORS]?: ColorSet; + trigger: ProfileTrigger; + compat?: AccountDataCompatVersion; +}; + +/** A reusable profile stored in the MSC4461 account-data catalog. */ +export type Persona = PerMessageProfileMsc4461; + +export type PerMessageProfileBeeperFormat = { + id: string; + displayname?: string; + avatar_url?: string; + [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]?: PronounSet[]; + [MATRIX_UNSTABLE_COLORS]?: ColorSet; + has_fallback?: boolean; +}; + +export type ResolvedPersonaSelection = { + persona: Persona; + validUntil?: number; +}; diff --git a/src/app/persona/projection.ts b/src/app/persona/projection.ts new file mode 100644 index 0000000000..f32b3ad62c --- /dev/null +++ b/src/app/persona/projection.ts @@ -0,0 +1,58 @@ +import { + MATRIX_UNSTABLE_COLORS, + MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME, +} from '$unstable/prefixes'; +import type { PerMessageProfileBeeperFormat, Persona } from './index'; + +export function convertPerMessageProfileToBeeperFormat( + profile: Persona, + has_fallback: boolean +): PerMessageProfileBeeperFormat { + const beeperPMP: PerMessageProfileBeeperFormat = { + id: profile.id, + displayname: profile.displayname, + avatar_url: profile.avatar_url, + [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]: + profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME], + [MATRIX_UNSTABLE_COLORS]: profile[MATRIX_UNSTABLE_COLORS], + has_fallback, + }; + if (!profile.displayname || profile.displayname.trim().length === 0) delete beeperPMP.displayname; + if (!profile.avatar_url) delete beeperPMP.avatar_url; + if ( + !profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME] || + profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]?.length === 0 + ) + delete beeperPMP[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]; + if (!profile[MATRIX_UNSTABLE_COLORS]) delete beeperPMP[MATRIX_UNSTABLE_COLORS]; + if (!has_fallback) delete beeperPMP.has_fallback; + return beeperPMP; +} + +export function convertBeeperFormatToOurPerMessageProfile( + beeperProfile: PerMessageProfileBeeperFormat +): Persona { + return { + id: beeperProfile.id, + displayname: beeperProfile.displayname ?? '', + avatar_url: beeperProfile.avatar_url, + [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]: + beeperProfile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME], + [MATRIX_UNSTABLE_COLORS]: beeperProfile[MATRIX_UNSTABLE_COLORS], + trigger: { prefix: [] }, + }; +} + +/** Projects a stored persona to the fields that may be sent with a message. */ +export function projectPersona(persona: Persona): PerMessageProfileBeeperFormat { + return convertPerMessageProfileToBeeperFormat(persona, false); +} + +export function stripPerMessageProfilePlainBody(formatted_body: string, profile?: Persona): string { + if (profile) return formatted_body.replace(`${profile.displayname}: `, ''); + return formatted_body.replace(/^.*?: /, ''); +} + +export function stripPerMessageProfileFormattedBody(formatted_body: string): string { + return formatted_body.replace(/^]*>.*?<\/strong>/, ''); +} diff --git a/src/app/persona/proxy.test.ts b/src/app/persona/proxy.test.ts new file mode 100644 index 0000000000..cc243c0d71 --- /dev/null +++ b/src/app/persona/proxy.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import type { Persona } from './index'; +import { resolvePersonaProxy } from './proxy'; + +describe('resolvePersonaProxy', () => { + const persona: Persona = { + id: 'persona', + displayname: 'Persona', + trigger: { + prefix: ['p: '], + 'net.f0rest.suffix': [' :p'], + 'net.f0rest.circumfix': [{ prefix: '[', suffix: ']' }], + }, + }; + + it('strips prefix, suffix, and circumfix triggers', () => { + expect(resolvePersonaProxy([persona], 'p: hello')?.body).toBe('hello'); + expect(resolvePersonaProxy([persona], 'hello :p')?.body).toBe('hello'); + expect(resolvePersonaProxy([persona], '[hello]')?.body).toBe('hello'); + }); +}); diff --git a/src/app/persona/proxy.ts b/src/app/persona/proxy.ts new file mode 100644 index 0000000000..c6ada03db5 --- /dev/null +++ b/src/app/persona/proxy.ts @@ -0,0 +1,28 @@ +import { + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME, + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME, +} from '$unstable/prefixes'; +import type { Persona } from './index'; + +/** Resolves the first matching MSC4461 trigger and strips it. */ +export function resolvePersonaProxy(personas: readonly Persona[], body: string) { + for (const persona of personas) { + const prefix = persona.trigger.prefix.find((trigger) => body.startsWith(trigger)); + if (prefix !== undefined) return { persona, body: body.slice(prefix.length).trimStart() }; + + const suffix = persona.trigger[ + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME + ]?.find((trigger) => body.endsWith(trigger)); + if (suffix !== undefined) return { persona, body: body.slice(0, -suffix.length).trimEnd() }; + + const circumfix = persona.trigger[ + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME + ]?.find(({ prefix: start, suffix: end }) => body.startsWith(start) && body.endsWith(end)); + if (circumfix !== undefined) + return { + persona, + body: body.slice(circumfix.prefix.length, -circumfix.suffix.length).trim(), + }; + } + return undefined; +} diff --git a/src/app/persona/selection.test.ts b/src/app/persona/selection.test.ts new file mode 100644 index 0000000000..5e118224be --- /dev/null +++ b/src/app/persona/selection.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import type { Persona } from './index'; +import { resolvePersona } from './selection'; + +const persona = (id: string): Persona => ({ id, displayname: id, trigger: { prefix: [] } }); + +describe('resolvePersona', () => { + it('uses proxy, latched, room, then account precedence', () => { + const proxy = persona('proxy'); + const latched = persona('latched'); + const room = persona('room'); + const account = persona('account'); + + expect( + resolvePersona({ + proxy, + latched, + room: { persona: room }, + account: { persona: account }, + now: 1, + }) + ).toBe(proxy); + expect( + resolvePersona({ latched, room: { persona: room }, account: { persona: account }, now: 1 }) + ).toBe(latched); + expect(resolvePersona({ room: { persona: room }, account: { persona: account }, now: 1 })).toBe( + room + ); + }); + + it('ignores expired selections', () => { + const account = persona('account'); + expect( + resolvePersona({ + room: { persona: persona('room'), validUntil: 1 }, + account: { persona: account }, + now: 1, + }) + ).toBe(account); + }); +}); diff --git a/src/app/persona/selection.ts b/src/app/persona/selection.ts new file mode 100644 index 0000000000..9005dfe05f --- /dev/null +++ b/src/app/persona/selection.ts @@ -0,0 +1,22 @@ +import type { Persona, ResolvedPersonaSelection } from './index'; + +export function resolvePersona({ + proxy, + latched, + room, + account, + now, +}: { + proxy?: Persona; + latched?: Persona; + room?: ResolvedPersonaSelection; + account?: ResolvedPersonaSelection; + now: number; +}): Persona | undefined { + if (proxy) return proxy; + if (latched) return latched; + if (room && (room.validUntil === undefined || room.validUntil > now)) return room.persona; + if (account && (account.validUntil === undefined || account.validUntil > now)) + return account.persona; + return undefined; +} diff --git a/tsconfig.json b/tsconfig.json index 6055562daa..6aa1a7bd1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "$plugins/*": ["./src/app/plugins/*"], "$components/*": ["./src/app/components/*"], "$features/*": ["./src/app/features/*"], + "$app/*": ["./src/app/*"], "$state/*": ["./src/app/state/*"], "$styles/*": ["./src/app/styles/*"], "$utils/*": ["./src/app/utils/*"], diff --git a/vite.config.ts b/vite.config.ts index f980a90c89..c587431fea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -167,6 +167,7 @@ export default defineConfig(({ command }) => { $public: path.resolve(__dirname, 'public'), $client: path.resolve(__dirname, 'src/client'), $unstable: path.resolve(__dirname, 'src/unstable'), + $app: path.resolve(__dirname, 'src/app'), }, }, server: { diff --git a/vitest.config.ts b/vitest.config.ts index 6dbada7b99..0c78e49130 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ $public: path.resolve(__dirname, 'public'), $client: path.resolve(__dirname, 'src/client'), $unstable: path.resolve(__dirname, 'src/unstable'), + $app: path.resolve(__dirname, 'src/app'), '@choochmeque/tauri-plugin-notifications-api': path.resolve( __dirname, 'src/test/choochmeque-notifications-stub.ts'