diff --git a/.changeset/add_pluralkit_import_pmp_backuprestorewipe_functionality.md b/.changeset/add_pluralkit_import_pmp_backuprestorewipe_functionality.md new file mode 100644 index 0000000000..4a5f1b8708 --- /dev/null +++ b/.changeset/add_pluralkit_import_pmp_backuprestorewipe_functionality.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Add PluralKit import, PMP backup/restore/wipe functionality diff --git a/src/app/features/room/persona-picker/PersonaPicker.test.tsx b/src/app/features/room/persona-picker/PersonaPicker.test.tsx index b147d67ba3..5467b9e53f 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.test.tsx +++ b/src/app/features/room/persona-picker/PersonaPicker.test.tsx @@ -14,6 +14,12 @@ const mocked = vi.hoisted(() => ({ setAccount: vi.fn<(...args: unknown[]) => Promise>(), })); +const grabPersonaButton = (name: string) => { + const btn = screen.getByText(name).parentElement!.parentElement!; + if (btn.tagName !== 'BUTTON') throw new Error('Not a button, DOM layout changed!'); + return btn; +}; + vi.mock('$app/persona/catalog', () => ({ ProfileCatalog: class { constructor(private readonly mx: MatrixClient) {} @@ -100,6 +106,7 @@ vi.mock('folds', async () => { Scroll: TestContainer, Text: TestContainer, config: { space: { S200: 0 } }, + color: { Surface: { OnContainer: '#000000' } }, toRem: (value: number) => `${value}rem`, }; }); @@ -187,10 +194,7 @@ describe('PersonaPicker async flows', () => { renderPicker(); await waitFor(() => { - expect(screen.getByRole('button', { name: 'First' })).toHaveAttribute( - 'aria-selected', - 'true' - ); + expect(grabPersonaButton('First')).toHaveAttribute('aria-selected', 'true'); }); roomSync.reject(new Error('room sync failed')); @@ -204,17 +208,14 @@ describe('PersonaPicker async flows', () => { renderPicker(); await screen.findByText('First'); - fireEvent.click(screen.getByRole('button', { name: 'First' })); - fireEvent.click(screen.getByRole('button', { name: 'Second' })); + fireEvent.click(grabPersonaButton('First')); + fireEvent.click(grabPersonaButton('Second')); firstWrite.reject(new Error('first write failed')); secondWrite.resolve(); await waitFor(() => { - expect(screen.getByRole('button', { name: 'Second' })).toHaveAttribute( - 'aria-selected', - 'true' - ); + expect(grabPersonaButton('Second')).toHaveAttribute('aria-selected', 'true'); }); }); @@ -227,7 +228,7 @@ describe('PersonaPicker async flows', () => { const view = renderPicker(mx); await screen.findByText('First'); - fireEvent.click(screen.getByRole('button', { name: 'First' })); + fireEvent.click(grabPersonaButton('First')); view.rerender( { latchedPersona={undefined} /> ); - fireEvent.click(screen.getByRole('button', { name: 'Second' })); + fireEvent.click(grabPersonaButton('Second')); globalWrite.reject(new Error('global write failed')); roomWrite.resolve(); await waitFor(() => { - expect(screen.getByRole('button', { name: 'Second' })).toHaveAttribute( - 'aria-selected', - 'true' - ); + expect(grabPersonaButton('Second')).toHaveAttribute('aria-selected', 'true'); }); view.rerender( @@ -260,10 +258,7 @@ describe('PersonaPicker async flows', () => { latchedPersona={undefined} /> ); - expect(screen.getByRole('button', { name: 'First' })).not.toHaveAttribute( - 'aria-selected', - 'true' - ); + expect(grabPersonaButton('First')).not.toHaveAttribute('aria-selected', 'true'); }); it('reconciles an optimistic selection when its write is rejected', async () => { @@ -271,13 +266,10 @@ describe('PersonaPicker async flows', () => { renderPicker(); await screen.findByText('First'); - fireEvent.click(screen.getByRole('button', { name: 'First' })); + fireEvent.click(grabPersonaButton('First')); await waitFor(() => { - expect(screen.getByRole('button', { name: 'First' })).not.toHaveAttribute( - 'aria-selected', - 'true' - ); + expect(grabPersonaButton('First')).not.toHaveAttribute('aria-selected', 'true'); }); }); }); diff --git a/src/app/features/room/persona-picker/PersonaPicker.tsx b/src/app/features/room/persona-picker/PersonaPicker.tsx index eda5d3ab84..3170eb8eeb 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.tsx +++ b/src/app/features/room/persona-picker/PersonaPicker.tsx @@ -25,6 +25,7 @@ import { Text, toRem, Badge, + color, } from 'folds'; import type { MatrixClient } from 'matrix-js-sdk'; import { @@ -361,12 +362,24 @@ function PersonaPickerMenu({ } > - - {profile.displayname} - + + + {profile.displayname} + + + {profile.id} + + ))} diff --git a/src/app/features/settings/Persona/PerMessageProfileOverview.tsx b/src/app/features/settings/Persona/PerMessageProfileOverview.tsx index 5f329b7a5e..1cfa1faffd 100644 --- a/src/app/features/settings/Persona/PerMessageProfileOverview.tsx +++ b/src/app/features/settings/Persona/PerMessageProfileOverview.tsx @@ -1,12 +1,13 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; -import type { PerMessageProfileMsc4461 } from '$hooks/usePerMessageProfile'; +import type { PerMessageProfileMsc4461, Persona } from '$hooks/usePerMessageProfile'; import { addOrUpdatePerMessageProfile, getAllPerMessageProfiles, getPerMessageProfileById, + ProfileCatalog, } from '$hooks/usePerMessageProfile'; -import { useCallback, useEffect, useState } from 'react'; -import { Box, Button, Spinner, Text } from 'folds'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Box, Button, color, config, Dialog, Header, Spinner, Switch, Text } from 'folds'; import { generateShortId } from '$utils/shortIdGen'; import { SequenceCard, SequenceCardStyle } from '$components/sequence-card'; import { PerMessageProfileListItem } from './PerMessageProfileListItem'; @@ -16,6 +17,16 @@ import { MATRIX_UNSTABLE_COLORS, MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME, } from '$unstable/prefixes'; +import { downloadJsonFile } from '$app/utils/common'; +import { selectFile } from '$app/utils/dom'; +import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; +import { AsyncError } from '$components/AsyncError'; +import { + importPluralkitMembers, + type PerMessageProfilePluralkitFormat, + type PkImportOptions, +} from '$app/persona/pluralkit'; +import { readFileToString } from '$app/utils/file'; type PerMessageProfileOverviewProps = { onCreateProfile: (profile: PerMessageProfileMsc4461) => void; @@ -32,6 +43,10 @@ export function PerMessageProfileOverview({ const mx = useMatrixClient(); const [profiles, setProfiles] = useState([]); + const [confirmWipeData, setConfirmWipeData] = useState(false); + const [confirmPkImport, setConfirmPkImport] = useState(false); + const [pkImportSettings, setPkImportSettings] = useState(null); + useEffect(() => { const fetchProfiles = async () => { const fetchedProfiles = await getAllPerMessageProfiles(mx); @@ -57,56 +72,306 @@ export function PerMessageProfileOverview({ }, [mx, onCreateProfile]) ); + const [handlePkitImportState, handlePkitImport] = useAsyncCallback( + useCallback(async () => { + const file = await selectFile('application/json', false); + if (!file) throw new Error('No file provided'); + + const text = await readFileToString(file); + const { members }: { members: PerMessageProfilePluralkitFormat[] } = JSON.parse(text); + if (!members) throw new Error('Personas not found in file'); + + const catalog = new ProfileCatalog(mx); + await importPluralkitMembers(mx, catalog, members, pkImportSettings ?? {}); + + // refetch + const fetchedProfiles = await catalog.list(); + setProfiles(fetchedProfiles); + setConfirmPkImport(false); + }, [mx, pkImportSettings]) + ); + + // import, export, etc + const [handlePersonaExportState, handlePersonaExport] = useAsyncCallback( + useCallback(async () => { + const personas = await new ProfileCatalog(mx).list(); + const data = { personas }; + downloadJsonFile(JSON.stringify(data), 'persona'); + }, [mx]) + ); + + const [handlePersonaImportState, handlePersonaImport] = useAsyncCallback( + useCallback(async () => { + const file = await selectFile('application/json', false); + if (!file) throw new Error('No file provided'); + + const text = await readFileToString(file); + const json = JSON.parse(text); + if (!json.personas) throw new Error('Personas not found in file'); + + await new ProfileCatalog(mx).overwrite(json.personas as Persona[]); + + // refetch + const fetchedProfiles = await getAllPerMessageProfiles(mx); + setProfiles(fetchedProfiles); + }, [mx]) + ); + + const [handlePersonaWipeState, handlePersonaWipe] = useAsyncCallback( + useCallback(async () => { + await new ProfileCatalog(mx).overwrite([]); + + // refetch + const fetchedProfiles = await getAllPerMessageProfiles(mx); + setProfiles(fetchedProfiles); + setConfirmWipeData(false); + }, [mx]) + ); + + const manageError = useMemo(() => { + if (handlePersonaExportState.status == AsyncStatus.Error) return handlePersonaExportState.error; + if (handlePersonaImportState.status == AsyncStatus.Error) return handlePersonaImportState.error; + if (handlePersonaWipeState.status == AsyncStatus.Error) return handlePersonaWipeState.error; + return undefined; + }, [handlePersonaExportState, handlePersonaImportState, handlePersonaWipeState]); + return ( - - Personas - - - - {addState.status === AsyncStatus.Loading ? ( - - ) : ( - Add - )} - - } - /> - - - {profiles.map((profile) => ( + <> + + Personas + - + {addState.status === AsyncStatus.Loading ? ( + + ) : ( + Add + )} + + } /> - ))} - + + {profiles.map((profile) => ( + + + + ))} + + + Persona Management + + + + Add or update PluralKit members from system.json. Consider backing up + Persona data before importing. + + } + after={ + + } + > + + + + {manageError && ( + + {manageError.message} + + )} + + + + + + {confirmPkImport && ( + setConfirmPkImport(false)}> + +
+ + Import PluralKit data + +
+ + + + Configure import + + + + + Extract pronouns from display names + + setPkImportSettings((opts) => { + return { ...opts, extractPronouns: on }; + }) + } + /> + + + + + + + + + +
+
+ )} + + {confirmWipeData && ( + setConfirmWipeData(false)}> + +
+ + Wipe all Persona data + +
+ + Are you sure you want to wipe all Persona data? + + + + + +
+
+ )} +
+
+
+ ); } diff --git a/src/app/features/settings/cosmetics/ThemeImportModal.tsx b/src/app/features/settings/cosmetics/ThemeImportModal.tsx index 950240114c..940282b8f3 100644 --- a/src/app/features/settings/cosmetics/ThemeImportModal.tsx +++ b/src/app/features/settings/cosmetics/ThemeImportModal.tsx @@ -19,6 +19,7 @@ import { import { pruneThemeFavorites, pruneThemeTweakFavorites } from '../../../theme/themeLibrary'; import { usePatchSettings } from './themeSettingsPatch'; +import { readFileToString } from '$app/utils/file'; type ThemeImportModalProps = { open: boolean; @@ -128,11 +129,9 @@ export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) { if (!file) return; setImportFileName(file.name); setImportPaste(''); - const reader = new FileReader(); - reader.addEventListener('load', () => { - setUploadedFileCss(typeof reader.result === 'string' ? reader.result : ''); - }); - reader.readAsText(file); + readFileToString(file) + .then(setUploadedFileCss) + .catch((err: Error) => setImportError(err.message)); }; const handleImportTheme = useCallback(async () => { diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 469dddf1aa..aed1ab18ac 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -111,6 +111,8 @@ export const settingsLinkFocusIdsBySection: Record { const catalog = new ProfileCatalog(mx); await Promise.all([ - catalog.upsert({ id: 'first', displayname: 'First', trigger: { prefix: [] } }), - catalog.upsert({ id: 'second', displayname: 'Second', trigger: { prefix: [] } }), + catalog.merge({ id: 'first', displayname: 'First', trigger: { prefix: [] } }), + catalog.merge({ id: 'second', displayname: 'Second', trigger: { prefix: [] } }), ]); await expect(catalog.list()).resolves.toEqual([ diff --git a/src/app/persona/catalog.ts b/src/app/persona/catalog.ts index 29f2fc1956..f17bf83616 100644 --- a/src/app/persona/catalog.ts +++ b/src/app/persona/catalog.ts @@ -2,7 +2,7 @@ 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 { type PronounSet } from '$utils/pronouns'; import { createKeyedQueue } from '$utils/keyedQueue'; import { MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME, @@ -309,7 +309,7 @@ export class ProfileCatalog { return (await this.list({ migrate: false })).find((persona) => persona.id === id); } - async upsert(persona: Persona): Promise { + async merge(persona: Persona): Promise { await enqueueProfilePersistence('catalog', async () => { const personas = await this.load(true); const index = personas.findIndex((existing) => existing.id === persona.id); @@ -317,7 +317,9 @@ export class ProfileCatalog { this.mx, index === -1 ? [...personas, persona] - : personas.map((existing) => (existing.id === persona.id ? persona : existing)) + : personas.map((existing) => + existing.id === persona.id ? { ...existing, ...persona } : existing + ) ); }); } @@ -345,6 +347,12 @@ export class ProfileCatalog { }); } + async overwrite(profiles: Persona[]): Promise { + await enqueueProfilePersistence('catalog', async () => { + await saveCatalog(this.mx, profiles); + }); + } + async getSelection( scope: 'account' | { roomId: string } ): Promise { diff --git a/src/app/persona/index.ts b/src/app/persona/index.ts index 07512f28e9..7b227d341d 100644 --- a/src/app/persona/index.ts +++ b/src/app/persona/index.ts @@ -4,6 +4,7 @@ import type { MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME, MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME, MATRIX_UNSTABLE_COLORS, + MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME, MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME, } from '$unstable/prefixes'; import type { ColorSet } from '$hooks/useUserProfile'; @@ -17,12 +18,20 @@ export type ProfileTrigger = { }[]; }; +export type PkitImport = { + id: string; + uuid?: string; + description?: string; + avatar_url?: string; +}; + export type PerMessageProfileMsc4461 = { id: string; displayname: string; avatar_url?: string; [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]?: PronounSet[]; [MATRIX_UNSTABLE_COLORS]?: ColorSet; + [MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]?: PkitImport; trigger: ProfileTrigger; compat?: AccountDataCompatVersion; }; diff --git a/src/app/persona/pluralkit.test.ts b/src/app/persona/pluralkit.test.ts new file mode 100644 index 0000000000..5e3107eb30 --- /dev/null +++ b/src/app/persona/pluralkit.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { MatrixClient } from '$types/matrix-sdk'; +import { + MATRIX_UNSTABLE_COLORS, + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME, +} from '$unstable/prefixes'; +import { ProfileCatalog } from './catalog'; +import { importPluralkitMembers } from './pluralkit'; + +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('PluralkitImport', () => { + it('noop import', async () => { + const { mx } = createMatrixClient( + new Map([ + [ + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + { + profiles: [], + }, + ], + ]), + true + ); + + const catalog = new ProfileCatalog(mx); + await expect(importPluralkitMembers(mx, catalog, [])).resolves.not.toThrow(); + await expect(catalog.list({ migrate: false })).resolves.toEqual([]); + }); + + it('basic import', async () => { + const { mx } = createMatrixClient( + new Map([ + [ + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + { + profiles: [], + }, + ], + ]), + true + ); + + const pkData = [ + { + id: '0', + name: 'foo', + uuid: 'f00b4r', + display_name: 'Foo Bar', + proxy_tags: [ + { + prefix: 'Foo:', + suffix: null, + }, + ], + }, + ]; + + const catalog = new ProfileCatalog(mx); + await expect(importPluralkitMembers(mx, catalog, pkData)).resolves.not.toThrow(); + await expect(catalog.list({ migrate: false })).resolves.toEqual([ + { + id: 'foo', + displayname: 'Foo Bar', + trigger: { + prefix: ['Foo:'], + }, + [MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]: { id: '0', uuid: 'f00b4r' }, + }, + ]); + }); + it('basic import w prev data', async () => { + const { mx } = createMatrixClient( + new Map([ + [ + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + { + profiles: [{ id: 'valid', displayname: 'Valid', trigger: { prefix: [] } }], + }, + ], + ]), + true + ); + + const pkData = [ + { + id: '0', + name: 'foo', + uuid: 'f00b4r', + display_name: 'Foo Bar', + proxy_tags: [ + { + prefix: 'Foo:', + suffix: null, + }, + ], + }, + ]; + + const catalog = new ProfileCatalog(mx); + await expect(importPluralkitMembers(mx, catalog, pkData)).resolves.not.toThrow(); + await expect(catalog.list()).resolves.toEqual([ + { id: 'valid', displayname: 'Valid', trigger: { prefix: [] } }, + { + id: 'foo', + displayname: 'Foo Bar', + trigger: { + prefix: ['Foo:'], + }, + [MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]: { id: '0', uuid: 'f00b4r' }, + }, + ]); + }); + + it('basic update-kind import', async () => { + const { mx } = createMatrixClient( + new Map([ + [ + MATRIX_UNSTABLE_MSC4461_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, + { + profiles: [ + { + id: 'foo', + displayname: 'Foo Bar', + trigger: { + prefix: ['Foo:'], + }, + [MATRIX_UNSTABLE_COLORS]: { + on_dark: '#ffffff', + on_light: '#000000', + }, + [MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]: { id: '0', uuid: 'f00b4r' }, + }, + ], + }, + ], + ]), + true + ); + + const pkData = [ + { + id: '0', + name: 'bar', + uuid: 'f00b4r', + display_name: 'Bar Bar', + proxy_tags: [ + { + prefix: 'Foo:', + suffix: null, + }, + ], + }, + ]; + + const catalog = new ProfileCatalog(mx); + await expect(importPluralkitMembers(mx, catalog, pkData)).resolves.not.toThrow(); + await expect(catalog.list()).resolves.toEqual([ + { + id: 'bar', + displayname: 'Bar Bar', + trigger: { + prefix: ['Foo:'], + }, + [MATRIX_UNSTABLE_COLORS]: { + on_dark: '#ffffff', + on_light: '#000000', + }, + [MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]: { id: '0', uuid: 'f00b4r' }, + }, + ]); + }); +}); diff --git a/src/app/persona/pluralkit.ts b/src/app/persona/pluralkit.ts new file mode 100644 index 0000000000..600a69a5ca --- /dev/null +++ b/src/app/persona/pluralkit.ts @@ -0,0 +1,100 @@ +import { uploadContent } from '$app/utils/matrix'; +import type { MatrixClient, MatrixError } from '$types/matrix-sdk'; +import { getParsedPronouns } from '$utils/pronouns'; +import type { ProfileCatalog } from './catalog'; +import { convertPluralkitFormatToOurPerMessageProfile } from './projection'; + +/** Used for PluralKit import */ +export type PerMessageProfilePluralkitFormat = { + id: string; + uuid?: string; + name: string; + display_name?: string; + color?: string; + pronouns?: string; + avatar_url?: string; + description?: string; + proxy_tags?: { prefix: string | null; suffix: string | null }[]; +}; + +export async function fetchPkitAvatar(mx: MatrixClient, url: string): Promise { + const req = await fetch(url); + if (!req.ok) throw new Error(`Fetch request to ${url} failed with code ${req.status}.`); + + const content = await req.blob(); + const uploadFile = new File([content], 'filename'); + + return new Promise((resolve, reject) => { + uploadContent(mx, uploadFile, { + onSuccess: resolve, + onError: function (error: MatrixError): void { + reject(error); + }, + }); + }); +} + +export type PkImportOptions = { + extractPronouns?: boolean; +}; + +export async function mergePluralkitProfile( + mx: MatrixClient, + catalog: ProfileCatalog, + pkPersona: PerMessageProfilePluralkitFormat, + options?: PkImportOptions +): Promise { + const personas = await catalog.list({ migrate: false }); + + // extract pronouns before converting to our Persona format + if (options?.extractPronouns && !!pkPersona.display_name) { + const { cleanedDisplayName, inlinePronoun } = getParsedPronouns(pkPersona.display_name, true); + + if (inlinePronoun) { + pkPersona.display_name = cleanedDisplayName; + pkPersona.pronouns = inlinePronoun; + } + } + const newPersona = convertPluralkitFormatToOurPerMessageProfile(pkPersona); + + // look for an old pluralkit profile + const matchingPersonaIndex = personas.findIndex( + ({ ['net.f0rest.pkimport']: record }) => + (!!pkPersona.uuid && record?.uuid == pkPersona.uuid) || record?.id == pkPersona.id + ); + const oldPersona = matchingPersonaIndex !== -1 ? personas[matchingPersonaIndex] : null; + + if (pkPersona.avatar_url) { + // check if we can reuse the old avatar url (check pk avatar urls) + if ( + oldPersona && + oldPersona.avatar_url && + oldPersona['net.f0rest.pkimport']?.avatar_url && + oldPersona['net.f0rest.pkimport']?.avatar_url === pkPersona.avatar_url + ) { + newPersona.avatar_url = oldPersona.avatar_url; + } else { + newPersona.avatar_url = await fetchPkitAvatar(mx, pkPersona.avatar_url); + } + } + + // if we find a pluralkit match but the id has changed, rename before merge + if (oldPersona && oldPersona.id !== newPersona.id) + await catalog.rename(oldPersona.id, newPersona.id); + await catalog.merge(newPersona); +} + +export async function importPluralkitMembers( + mx: MatrixClient, + catalog: ProfileCatalog, + pkitPersonas: PerMessageProfilePluralkitFormat[], + options?: PkImportOptions +): Promise { + for (const pkPersona of pkitPersonas) { + // i don't want to deal with the potential for race conditions, + // and i'm fine with an import being slow + // + // oxlint-disable-next-line no-await-in-loop + await mergePluralkitProfile(mx, catalog, pkPersona, options); + } +} diff --git a/src/app/persona/projection.ts b/src/app/persona/projection.ts index f32b3ad62c..a657cd5e7f 100644 --- a/src/app/persona/projection.ts +++ b/src/app/persona/projection.ts @@ -1,8 +1,16 @@ import { + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME, + MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME, MATRIX_UNSTABLE_COLORS, + MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME, MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME, } from '$unstable/prefixes'; -import type { PerMessageProfileBeeperFormat, Persona } from './index'; +import type { PerMessageProfileBeeperFormat, Persona, ProfileTrigger } from './index'; +import chroma from 'chroma-js'; +import { ThemeKind } from '$hooks/useTheme'; +import { accessibleColor } from '$plugins/color'; +import { parsePronounsInput } from '$app/utils/pronouns'; +import type { PerMessageProfilePluralkitFormat } from './pluralkit'; export function convertPerMessageProfileToBeeperFormat( profile: Persona, @@ -48,6 +56,85 @@ export function projectPersona(persona: Persona): PerMessageProfileBeeperFormat return convertPerMessageProfileToBeeperFormat(persona, false); } +export function convertPluralkitFormatToOurPerMessageProfile( + pkitProfile: PerMessageProfilePluralkitFormat, + pkitAvatarUrl?: string +): Persona { + // parse colors + const color = { [MATRIX_UNSTABLE_COLORS]: {} }; + if (pkitProfile.color && chroma.valid(pkitProfile.color)) { + const lightness = chroma(pkitProfile.color).lab()[0]; + const should_be_on_dark = lightness > 50; + if (should_be_on_dark) { + color[MATRIX_UNSTABLE_COLORS] = { + on_dark: '#' + pkitProfile.color, + on_light: accessibleColor(ThemeKind.Light, pkitProfile.color), + }; + } else { + color[MATRIX_UNSTABLE_COLORS] = { + on_dark: accessibleColor(ThemeKind.Dark, pkitProfile.color), + on_light: '#' + pkitProfile.color, + }; + } + } + + // parse proxy tags + const trigger: ProfileTrigger = { + prefix: [], + }; + if (pkitProfile.proxy_tags) { + pkitProfile.proxy_tags.forEach( + ({ prefix, suffix }: { prefix: string | null; suffix: string | null }) => { + if (prefix && suffix) { + (trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME] ??= []).push({ + prefix, + suffix, + }); + } else if (!prefix && suffix) { + (trigger[MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME] ??= []).push(suffix); + } else if (prefix && !suffix) { + trigger.prefix.push(prefix); + } + } + ); + } + + const profile: Persona = { + id: pkitProfile.name, + displayname: pkitProfile.display_name ?? pkitProfile.name, + avatar_url: pkitAvatarUrl, + trigger, + [MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]: parsePronounsInput(pkitProfile.pronouns), + [MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]: { + id: pkitProfile.id, + uuid: pkitProfile.uuid, + description: pkitProfile.description, + avatar_url: pkitProfile.avatar_url, + }, + ...color, + }; + + if (!profile.avatar_url) delete profile.avatar_url; + if ( + !profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME] || + profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME].length === 0 + ) + delete profile[MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME]; + if ( + !profile[MATRIX_UNSTABLE_COLORS] || + !profile[MATRIX_UNSTABLE_COLORS].on_dark || + !profile[MATRIX_UNSTABLE_COLORS].on_light + ) + delete profile[MATRIX_UNSTABLE_COLORS]; + if (!profile[MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]?.avatar_url) + delete profile[MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]?.avatar_url; + if (!profile[MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]?.description) + delete profile[MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]?.description; + if (!profile[MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]?.uuid) + delete profile[MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME]?.uuid; + return profile; +} + export function stripPerMessageProfilePlainBody(formatted_body: string, profile?: Persona): string { if (profile) return formatted_body.replace(`${profile.displayname}: `, ''); return formatted_body.replace(/^.*?: /, ''); diff --git a/src/app/utils/file.ts b/src/app/utils/file.ts new file mode 100644 index 0000000000..a53daa2b5f --- /dev/null +++ b/src/app/utils/file.ts @@ -0,0 +1,11 @@ +export const readFileToString = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => + resolve(typeof reader.result === 'string' ? reader.result : '') + ); + reader.addEventListener('error', () => reject(new Error(`Could not read ${file.name}.`))); + + reader.readAsText(file); + }); +}; diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index 135f31ea1c..f2e26ea435 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -2,6 +2,7 @@ import { sanitizeThemeRemoteTweakFavorites, type Settings } from '$state/setting import { isLocalImportTweakUrl } from '../theme/localImportUrls'; import { sanitizeShortcutOverrides } from '../keyboard/shortcuts'; import { saveFileToDevice } from './download'; +import { readFileToString } from './file'; /** * Keys excluded from cross-device sync. @@ -178,17 +179,12 @@ export const importSettingsFromJson = (currentSettings: Settings): Promise { - try { - const data = JSON.parse(e.target?.result as string); - resolve(deserializeFromSync(data, currentSettings)); - } catch { - resolve(null); - } - }); - reader.addEventListener('error', () => resolve(null)); - reader.readAsText(file); + + readFileToString(file) + .then((result) => { + resolve(deserializeFromSync(JSON.parse(result), currentSettings)); + }) + .catch(() => resolve(null)); }); // oncancel is not widely supported; clicking away without selecting resolves naturally via onchange with empty files input.click(); diff --git a/src/unstable/prefixes/msc/profile.ts b/src/unstable/prefixes/msc/profile.ts index 94da45319a..db6236e0f0 100644 --- a/src/unstable/prefixes/msc/profile.ts +++ b/src/unstable/prefixes/msc/profile.ts @@ -28,3 +28,6 @@ export const MATRIX_UNSTABLE_COLORS = 'eu.she-a.color'; export const MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_SUFFIX_PROPERTY_NAME = 'net.f0rest.suffix'; export const MATRIX_SABLE_UNSTABLE_MSC4461_TRIGGER_CIRCUMFIX_PROPERTY_NAME = 'net.f0rest.circumfix'; + +/** Unstable prefix for pluralkit imports */ +export const MATRIX_UNSTABLE_PROFILE_PKIT_IMPORT_PROPERTY_NAME = 'net.f0rest.pkimport';