diff --git a/.changeset/add_pmp_sending.md b/.changeset/add_pmp_sending.md new file mode 100644 index 000000000..5b5c24194 --- /dev/null +++ b/.changeset/add_pmp_sending.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +added the posibility to send using per message profiles with `/usepmp` diff --git a/src/app/features/common-settings/cosmetics/Cosmetics.tsx b/src/app/features/common-settings/cosmetics/Cosmetics.tsx index dbfbd1390..be793be7b 100644 --- a/src/app/features/common-settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/common-settings/cosmetics/Cosmetics.tsx @@ -56,7 +56,8 @@ import { ImageEditor } from '$components/image-editor'; import { stopPropagation } from '$utils/keyboard'; import { ModalWide } from '$styles/Modal.css'; import { NameColorEditor } from '$features/settings/account/NameColorEditor'; -import { PronounEditor, PronounSet } from '$features/settings/account/PronounEditor'; +import { PronounEditor } from '$features/settings/account/PronounEditor'; +import { PronounSet } from '$utils/pronouns'; const log = createLogger('Cosmetics'); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index be7a468b4..fb4084c36 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -153,6 +153,10 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; +import { + convertPerMessageProfileToBeeperFormat, + getCurrentlyUsedPerMessageProfileForRoom, +} from '$hooks/usePerMessageProfile'; import { Microphone, Stop } from '@phosphor-icons/react'; import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; import { SchedulePickerDialog } from './schedule-send'; @@ -684,6 +688,13 @@ export const RoomInput = forwardRef( body, }; + const perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + + if (perMessageProfile) { + content['com.beeper.per_message_profile'] = + convertPerMessageProfileToBeeperFormat(perMessageProfile); + } + if (replyDraft && !silentReply) { mentionData.users.add(replyDraft.userId); } @@ -794,7 +805,6 @@ export const RoomInput = forwardRef( canSendReaction, mx, roomId, - threadRootId, replyDraft, silentReply, scheduledTime, @@ -802,12 +812,13 @@ export const RoomInput = forwardRef( handleQuickReact, commands, sendTypingStatus, + room, queryClient, + threadRootId, setReplyDraft, isEncrypted, setEditingScheduledDelayId, setScheduledTime, - room, ]); const handleKeyDown: KeyboardEventHandler = useCallback( diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 9dd200ed5..b09562714 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -85,6 +85,10 @@ import { addStickerToDefaultPack, doesStickerExistInDefaultPack, } from '$utils/addStickerToDefaultStickerPack'; +import { + convertBeeperFormatToOurPerMessageProfile, + PerMessageProfileBeeperFormat, +} from '$hooks/usePerMessageProfile'; import { MessageEditor } from './MessageEditor'; import * as css from './styles.css'; @@ -280,6 +284,10 @@ function useMobileDoubleTap(callback: () => void, delay = 300) { ); } +/** + * Component to render pronouns in the chat timeline. + * It also filters them. + */ const Pronouns = as< 'span', { @@ -295,6 +303,13 @@ const Pronouns = as< .map((lang) => lang.trim().toLowerCase()) .filter(Boolean); + /** + * filter the pronouns based on the user's language settings. + * If filtering is enabled, only show pronouns that match the selected languages. + * If filtering is disabled, show all pronouns but still apply the language filter to determine which pronouns to show if there are multiple sets of pronouns for different languages. + * If there are multiple sets of pronouns and filtering is enabled, only show the ones that match the selected languages. + * If there are no pronouns that match the selected languages, show all pronouns. + */ const visiblePronouns = filterPronounsByLanguage( pronouns, languageFilterEnabled, @@ -365,22 +380,33 @@ function MessageInternal( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const pmp = useMemo( + const pmp: PerMessageProfileBeeperFormat | undefined = useMemo( () => mEvent.event.content?.['com.beeper.per_message_profile'] as - | { - avatar_url: string | undefined; - displayname: string | undefined; - id: string | undefined; - } + | PerMessageProfileBeeperFormat | undefined, [mEvent] ); + /** + * We convert the per-message profile from the Beeper format to our internal format here in the message component + */ + const parsedPMPContent = useMemo(() => { + if (!pmp) return undefined; + return convertBeeperFormatToOurPerMessageProfile(pmp); + }, [pmp]); + // Profiles and Colors const profile = useUserProfile(senderId, room); const { color: usernameColor, font: usernameFont } = useSableCosmetics(senderId, room); + /** + * If there is a per-message profile, we want to use the per message pronouns, + * otherwise we fall back to the profile pronouns. + * This allows users to set pronouns on a per-message basis, while still falling back to their profile pronouns if they don't set any for a specific message. + */ + const pronouns = parsedPMPContent?.pronouns ?? profile.pronouns; + // Avatars // Prefer the room-scoped member avatar (m.room.member) over the global profile // avatar so per-room avatar overrides are respected in the timeline. @@ -461,7 +487,7 @@ function MessageInternal( {showPronouns && ( - + )} {tagIconSrc && } diff --git a/src/app/features/settings/Persona/PerMessageProfileEditor.tsx b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx new file mode 100644 index 000000000..0e6094923 --- /dev/null +++ b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx @@ -0,0 +1,464 @@ +import { SequenceCard } from '$components/sequence-card'; +import { Box, Button, Text, Avatar, config, Icon, IconButton, Icons, Input } from 'folds'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useCallback, useMemo, useState } from 'react'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { useFilePicker } from '$hooks/useFilePicker'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useObjectURL } from '$hooks/useObjectURL'; +import { createUploadAtom } from '$state/upload'; +import { UserAvatar } from '$components/user-avatar'; +import { CompactUploadCardRenderer } from '$components/upload-card'; +import { + addOrUpdatePerMessageProfile, + deletePerMessageProfile, + invalidatePerMessageProfileForProfileId, + renamePerMessageProfile, +} from '$hooks/usePerMessageProfile'; +import { parsePronounsStringToPronounsSetArray, PronounSet } from '$utils/pronouns'; +import { SequenceCardStyle } from '../styles.css'; + +/** + * the props we use for the per-message profile editor, which is used to edit a per-message profile. This is used in the settings page when the user wants to edit a profile. + */ +type PerMessageProfileEditorProps = { + mx: MatrixClient; + profileId: string; + avatarMxcUrl?: string; + displayName?: string; + pronouns?: PronounSet[]; + onChange?: (profile: { id: string; name: string; avatarUrl?: string }) => void; + onDelete?: (profileId: string) => void; +}; + +export function PerMessageProfileEditor({ + mx, + profileId, + avatarMxcUrl, + displayName, + pronouns = Array(), + onChange, + onDelete, +}: Readonly) { + const useAuthentication = useMediaAuthentication(); + const [currentDisplayName, setCurrentDisplayName] = useState(displayName ?? ''); + const [currentId, setCurrentId] = useState(profileId); + const [newId, setNewId] = useState(profileId); + + console.warn(pronouns); + + // Pronouns + const [currentPronouns, setCurrentPronouns] = useState(pronouns); + const [newPronouns, setNewPronouns] = useState(pronouns); + const currentPronounsString = useMemo( + () => + Array.isArray(currentPronouns) + ? currentPronouns.map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`).join(', ') + : '', + [currentPronouns] + ); + const [newPronounsString, setNewPronounsString] = useState(() => { + const pronounsString = Array.isArray(newPronouns) + ? newPronouns.map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`).join(', ') + : ''; + return pronounsString; + }); + + const [newDisplayName, setNewDisplayName] = useState(currentDisplayName); + const [imageFile, setImageFile] = useState(); + const [avatarMxc, setAvatarMxc] = useState(avatarMxcUrl); + const imageFileURL = useObjectURL(imageFile); + const avatarUrl = useMemo(() => { + if (imageFileURL) return imageFileURL; + if (avatarMxc) { + return mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined; + } + return undefined; + }, [imageFileURL, avatarMxc, mx, useAuthentication]); + const uploadAtom = useMemo(() => { + if (imageFile) return createUploadAtom(imageFile); + return undefined; + }, [imageFile]); + const pickFile = useFilePicker(setImageFile, false); + const handleRemoveUpload = useCallback(() => { + setImageFile(undefined); + }, []); + const handleUploaded = useCallback( + (upload: { status: string; mxc: string }) => { + if (upload && upload.status === 'success') { + setAvatarMxc(upload.mxc); + if (onChange) onChange({ id: profileId, name: newDisplayName, avatarUrl: upload.mxc }); + } + setImageFile(undefined); + }, + [onChange, profileId, newDisplayName] + ); + const handleNameChange = useCallback((e: React.ChangeEvent) => { + setNewDisplayName(e.target.value); + }, []); + + const [changingDisplayName, setChangingDisplayName] = useState(false); + // This state is used to disable the display name input while the user is changing it, to prevent them from making changes while the save operation is in progress. + // It is set to true when the user clicks the save button, and set back to false when the save operation is complete. + const [disableSetDisplayname, setDisableSetDisplayname] = useState(false); + + const hasIdChange = useMemo(() => newId !== currentId, [newId, currentId]); + + const hasChanges = useMemo( + () => + newDisplayName !== (currentDisplayName ?? '') || + newPronounsString !== currentPronounsString || + hasIdChange || + !!imageFile, + [ + newDisplayName, + currentDisplayName, + newPronounsString, + currentPronounsString, + hasIdChange, + imageFile, + ] + ); + + /** + * Reset handler to reset the display name and pronouns to their current values, and clear the image file if there is one. + */ + const handleReset = useCallback(() => { + setNewDisplayName(currentDisplayName); + setNewPronouns(currentPronouns); + setNewPronounsString( + Array.isArray(currentPronouns) + ? currentPronouns.map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`).join(', ') + : '' + ); + setChangingDisplayName(false); + setDisableSetDisplayname(false); + }, [currentDisplayName, currentPronouns]); + + /** + * persisting the data :3 + */ + const handleSave = useCallback(() => { + addOrUpdatePerMessageProfile(mx, { + id: profileId, + name: newDisplayName, + avatarUrl: avatarMxc, + pronouns: newPronouns, + }).then(() => { + setCurrentDisplayName(newDisplayName); + setCurrentPronouns(newPronouns); + }); + if (hasIdChange) { + renamePerMessageProfile(mx, profileId, newId).then(() => { + setCurrentId(newId); + }); + } + setChangingDisplayName(false); + setDisableSetDisplayname(false); + invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); + }, [mx, profileId, newDisplayName, avatarMxc, newPronouns, hasIdChange, newId]); + + const handleDelete = useCallback(() => { + deletePerMessageProfile(mx, profileId).then(() => { + setCurrentDisplayName(''); + setCurrentPronouns([]); + if (onDelete) onDelete(profileId); + }); + }, [mx, profileId, onDelete]); + + const handleIdChange = useCallback((e: React.ChangeEvent) => { + setNewId(e.target.value); + }, []); + + const handlePronounsChange = useCallback((e: React.ChangeEvent) => { + setNewPronounsString(e.target.value); + return setNewPronouns(parsePronounsStringToPronounsSetArray(e.target.value)); + }, []); + + return ( + + + {/* Profile ID heading and input */} + + + Profile ID: + + + + + + + ( + + p + + )} + alt={`Avatar for profile ${profileId}`} + /> + + + {uploadAtom && ( + + + + )} + + + + Display Name: + + + + + ) + } + /> + + Pronouns: + + + + + ) + } + /> + + {/* Rechte Spalte: Save Button */} + + + + + + + + ); +} diff --git a/src/app/features/settings/Persona/PerMessageProfileOverview.tsx b/src/app/features/settings/Persona/PerMessageProfileOverview.tsx new file mode 100644 index 000000000..6ed0dcef0 --- /dev/null +++ b/src/app/features/settings/Persona/PerMessageProfileOverview.tsx @@ -0,0 +1,64 @@ +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { + addOrUpdatePerMessageProfile, + getAllPerMessageProfiles, + PerMessageProfile, +} from '$hooks/usePerMessageProfile'; +import { useEffect, useState } from 'react'; +import { Box, Button, Text } from 'folds'; +import { PerMessageProfileEditor } from './PerMessageProfileEditor'; + +/** + * Renders a list of per-message profiles along with an editor. + * @returns rendering of per message profile list including editor + */ +export function PerMessageProfileOverview() { + const mx = useMatrixClient(); + const [profiles, setProfiles] = useState([]); + + useEffect(() => { + const fetchProfiles = async () => { + const fetchedProfiles = await getAllPerMessageProfiles(mx); + setProfiles(fetchedProfiles); + }; + fetchProfiles(); + }, [mx]); + + // Handler to remove a profile from the list after deletion + const handleDelete = (profileId: string) => { + setProfiles((prevProfiles) => prevProfiles.filter((profile) => profile.id !== profileId)); + }; + + return ( + + + Per-Message Profiles + + + {profiles.map((profile) => ( + + ))} + + ); +} diff --git a/src/app/features/settings/Persona/ProfilesPage.tsx b/src/app/features/settings/Persona/ProfilesPage.tsx new file mode 100644 index 000000000..2fe56f51c --- /dev/null +++ b/src/app/features/settings/Persona/ProfilesPage.tsx @@ -0,0 +1,45 @@ +import { Page, PageHeader, PageNavContent } from '$components/page'; +import { Box, IconButton, Icon, Icons, Text } from 'folds'; +import { PerMessageProfileOverview } from './PerMessageProfileOverview'; + +type PerMessageProfilePageProps = { + requestClose: () => void; +}; + +export function PerMessageProfilePage({ requestClose }: PerMessageProfilePageProps) { + return ( + + + + + + Persona + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 34f350b3d..4724b61cf 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -36,10 +36,12 @@ import { General } from './general'; import { Cosmetics } from './cosmetics/Cosmetics'; import { Experimental } from './experimental/Experimental'; import { KeyboardShortcuts } from './keyboard-shortcuts'; +import { PerMessageProfilePage } from './Persona/ProfilesPage'; export enum SettingsPages { GeneralPage, AccountPage, + PerMessageProfilesPage, // Renamed to avoid conflict NotificationPage, DevicesPage, EmojisStickersPage, @@ -70,6 +72,11 @@ const useSettingsMenuItems = (): SettingsMenuItem[] => name: 'Account', icon: Icons.User, }, + { + page: SettingsPages.PerMessageProfilesPage, + name: 'Persona', + icon: Icons.User, + }, { page: SettingsPages.CosmeticsPage, name: 'Appearance', @@ -247,6 +254,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {activePage === SettingsPages.AccountPage && ( )} + {activePage === SettingsPages.PerMessageProfilesPage && ( + + )} {activePage === SettingsPages.CosmeticsPage && ( )} diff --git a/src/app/features/settings/account/AnimalCosmetics.tsx b/src/app/features/settings/account/AnimalCosmetics.tsx new file mode 100644 index 000000000..ada07ef99 --- /dev/null +++ b/src/app/features/settings/account/AnimalCosmetics.tsx @@ -0,0 +1,75 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { UserProfile } from '$hooks/useUserProfile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { profilesCacheAtom } from '$state/userRoomProfile'; +import { Box, Switch, Text } from 'folds'; +import { useSetAtom } from 'jotai'; +import { useCallback } from 'react'; +import { SequenceCardStyle } from '../styles.css'; + +type AnimalCosmeticsProps = { + profile: UserProfile; + userId: string; +}; +export function AnimalCosmetics({ profile, userId }: Readonly) { + const mx = useMatrixClient(); + const setGlobalProfiles = useSetAtom(profilesCacheAtom); + const [renderAnimals, setRenderAnimals] = useSetting(settingsAtom, 'renderAnimals'); + + const isCat = profile.isCat || profile.extended?.['kitty.meow.is_cat'] === true; + const hasCats = profile.hasCats || profile.extended?.['kitty.meow.has_cats'] === true; + + const handleSaveField = useCallback( + async (key: string, value: boolean) => { + await mx.setExtendedProfileProperty?.(key, value); + setGlobalProfiles((prev) => { + const newCache = { ...prev }; + delete newCache[userId]; + return newCache; + }); + }, + [mx, userId, setGlobalProfiles] + ); + + return ( + + Animal Identity + + } + /> + + + handleSaveField('kitty.meow.is_cat', !isCat)} + /> + } + /> + + + handleSaveField('kitty.meow.has_cats', !hasCats)} + /> + } + /> + + + ); +} diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 68daf9bf8..45c66effb 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -23,7 +23,6 @@ import { Header, config, Spinner, - Switch, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useSetAtom } from 'jotai'; @@ -47,13 +46,12 @@ import { useCapabilities } from '$hooks/useCapabilities'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { useUserPresence } from '$hooks/useUserPresence'; -import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; import { TimezoneEditor } from './TimezoneEditor'; import { PronounEditor } from './PronounEditor'; import { BioEditor } from './BioEditor'; import { NameColorEditor } from './NameColorEditor'; import { StatusEditor } from './StatusEditor'; +import { AnimalCosmetics } from './AnimalCosmetics'; type PronounSet = { summary: string; @@ -638,70 +636,6 @@ function ProfileExtended({ profile, userId }: Readonly) { ); } -type AnimalCosmeticsProps = { - profile: UserProfile; - userId: string; -}; -function AnimalCosmetics({ profile, userId }: Readonly) { - const mx = useMatrixClient(); - const setGlobalProfiles = useSetAtom(profilesCacheAtom); - const [renderAnimals, setRenderAnimals] = useSetting(settingsAtom, 'renderAnimals'); - - const isCat = profile.isCat || profile.extended?.['kitty.meow.is_cat'] === true; - const hasCats = profile.hasCats || profile.extended?.['kitty.meow.has_cats'] === true; - - const handleSaveField = useCallback( - async (key: string, value: boolean) => { - await mx.setExtendedProfileProperty?.(key, value); - setGlobalProfiles((prev) => { - const newCache = { ...prev }; - delete newCache[userId]; - return newCache; - }); - }, - [mx, userId, setGlobalProfiles] - ); - - return ( - - Animal Identity - - } - /> - - - handleSaveField('kitty.meow.is_cat', !isCat)} - /> - } - /> - - - handleSaveField('kitty.meow.has_cats', !hasCats)} - /> - } - /> - - - ); -} - export function Profile() { const mx = useMatrixClient(); const userId = mx.getUserId()!; diff --git a/src/app/features/settings/account/PronounEditor.tsx b/src/app/features/settings/account/PronounEditor.tsx index e6aeaf781..2de50a142 100644 --- a/src/app/features/settings/account/PronounEditor.tsx +++ b/src/app/features/settings/account/PronounEditor.tsx @@ -1,13 +1,7 @@ import { useState, useEffect, ChangeEvent } from 'react'; import { Input } from 'folds'; import { SettingTile } from '$components/setting-tile'; -import { parsePronounsInput } from '$utils/pronouns'; - -export type PronounSet = { - summary: string; - language?: string; - grammatical_gender?: string; -}; +import { parsePronounsInput, PronounSet } from '$utils/pronouns'; type PronounEditorProps = { title: string; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 199927883..216011023 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -35,6 +35,14 @@ import { sendFeedback } from '$utils/sendFeedbackToUser'; import { useRoomNavigate } from './useRoomNavigate'; import { enrichWidgetUrl } from './useRoomWidgets'; import { useUserProfile } from './useUserProfile'; +import { + addOrUpdatePerMessageProfile, + deletePerMessageProfile, + invalidatePerMessageProfile, + invalidatePerMessageProfileForProfileId, + PerMessageProfile, + setCurrentlyUsedPerMessageProfileIdForRoom, +} from './usePerMessageProfile'; export const SHRUG = String.raw`¯\_(ツ)_/¯`; export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻'; @@ -232,6 +240,9 @@ export enum Command { Font = 'font', SFont = 'sfont', AddWidget = 'addwidget', + AddPerMessageProfileToAccount = 'addpmp', + DeletePerMessageProfileFromAccount = 'delpmp', + UsePerMessageProfile = 'usepmp', Pronoun = 'pronoun', SPronoun = 'spronoun', Rainbow = 'rainbow', @@ -474,6 +485,127 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { ); }, }, + [Command.AddPerMessageProfileToAccount]: { + name: Command.AddPerMessageProfileToAccount, + description: + 'Add or update a per message profile to your account. Example: /addpmp profileId name=Profile Name avatar=mxc://xyzabc', + exe: async (payload) => { + // Parse key=value pairs + const parts = payload.split(' '); + let avatarUrl: string | undefined; + let name: string | undefined; + parts.forEach((part, index) => { + const [key, value] = part.split('='); + if (key && value) { + if (key === 'name' || key === 'avatar') { + if (key === 'name') { + name = parts + .slice(index) + .map((p) => p.split('=')[1]) + .join(' '); + return; + } + if (key === 'avatar') avatarUrl = value; + } + } + }); + + const profileId = parts[0]; // profileId is positional (before any key=) + + const pmp: PerMessageProfile = { + id: profileId, + name: name || '', + avatarUrl, + }; + await addOrUpdatePerMessageProfile(mx, pmp) + .then(() => { + sendFeedback( + `Per message profile "${profileId}" added/updated in account.`, + room, + mx.getSafeUserId() + ); + invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); + }) + .catch(() => { + sendFeedback( + `Failed to add/update per message profile "${profileId}" in account.`, + room, + mx.getSafeUserId() + ); + }); + }, + }, + [Command.DeletePerMessageProfileFromAccount]: { + name: Command.DeletePerMessageProfileFromAccount, + description: 'Delete a per message profile from your account. Example: /delpmp profileId', + exe: async (payload) => { + const [profileId] = splitWithSpace(payload); + if (profileId === 'index') { + // "index" is reserved for the profile index, reject it as a profile id + sendFeedback('Cannot delete reserved profile ID "index".', room, mx.getSafeUserId()); + return; + } + await deletePerMessageProfile(mx, profileId) + .then(() => { + sendFeedback( + `Per message profile "${profileId}" deleted from account.`, + room, + mx.getSafeUserId() + ); + invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); + }) + .catch(() => { + sendFeedback( + `Failed to delete per message profile "${profileId}" from account.`, + room, + mx.getSafeUserId() + ); + }); + }, + }, + [Command.UsePerMessageProfile]: { + name: Command.UsePerMessageProfile, + description: + 'Use a per message profile for this room once, or until reset. Example: /usepmp profileId [once,reset,or duration like 1h30m]', + exe: async (payload) => { + // this command doesn't need to do anything, the composer will pick it up and apply the profile to the message being composed + const profileId: string = splitWithSpace(payload)[0]; + const durationStr: string | undefined = splitWithSpace(payload)[1]; + let validUntil: number | undefined; + if (durationStr === 'reset') { + setCurrentlyUsedPerMessageProfileIdForRoom(mx, room.roomId, undefined, undefined, true) + .then(() => { + sendFeedback('Per message profile reset for this room.', room, mx.getSafeUserId()); + }) + .catch(() => { + sendFeedback( + 'Failed to reset per message profile for this room.', + room, + mx.getSafeUserId() + ); + }); + return; + } + await setCurrentlyUsedPerMessageProfileIdForRoom(mx, room.roomId, profileId, validUntil) + .then(() => { + sendFeedback( + `Per message profile "${profileId}" will be used for messages in this room for the until ${ + durationStr ?? 'reset' + }. Use \`/usepmp reset\` to reset it at any time.`, + room, + mx.getSafeUserId() + ); + invalidatePerMessageProfile(room.roomId, () => {}); + }) + .catch(() => { + sendFeedback( + 'Failed to set per message profile for this room.', + room, + mx.getSafeUserId() + ); + }); + }, + }, [Command.MyRoomAvatar]: { name: Command.MyRoomAvatar, description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc', diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts new file mode 100644 index 000000000..6faf70539 --- /dev/null +++ b/src/app/hooks/usePerMessageProfile.ts @@ -0,0 +1,314 @@ +import { PronounSet } from '$utils/pronouns'; +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useMatrixClient } from './useMatrixClient'; + +const ACCOUNT_DATA_PREFIX = 'fyi.cisnt.permessageprofile'; + +/** + * a per message profile + */ +export 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[]; +}; + +/** + * 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 + */ + 'io.fsky.nyx.pronouns'?: PronounSet[]; +}; + +/** + * 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: PerMessageProfile +): PerMessageProfileBeeperFormat { + return { + id: profile.id, + displayname: profile.name, + avatar_url: profile.avatarUrl, + 'io.fsky.nyx.pronouns': profile.pronouns, + }; +} + +/** + * 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 +): PerMessageProfile { + return { + id: beeperProfile.id, + name: beeperProfile.displayname, + avatarUrl: beeperProfile.avatar_url, + pronouns: beeperProfile['io.fsky.nyx.pronouns'], + }; +} + +type PerMessageProfileIndex = { + /** + * a list of all profile ids, used to list all profiles when the user wants to manage them. + */ + profileIds: string[]; +}; + +type PerMessageProfileRoomAssociation = { + /** + * the id of the profile to use for messages in this room. This is used to apply a profile to all messages in a room without having to set the profile for each message individually. + */ + profileId: string; + /** + * the id of the room this association applies to. + * This is used to apply a profile to all messages in a room without having to set the profile for each message individually. + */ + roomId: string; + validUntil?: number; // timestamp in ms until which this association is valid, after which it should be ignored and removed. If not set, the association is valid indefinitely until changed or removed. +}; + +/** + * 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 = { + /** + * a list of associations between rooms and profiles, which is used to determine which profile to apply to messages in a room when sending a message. + */ + associations: PerMessageProfileRoomAssociation[]; +}; + +export async function getPerMessageProfileById( + mx: MatrixClient, + id: string +): Promise { + const profile = await mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.${id}` as any); + return profile ? (profile.getContent() as unknown as PerMessageProfile) : undefined; +} + +export async function getAllPerMessageProfiles(mx: MatrixClient): Promise { + const profileData = await mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.index` as any); + const profileIds = (profileData?.getContent() as PerMessageProfileIndex)?.profileIds || []; + const profiles = await Promise.all(profileIds.map((id) => getPerMessageProfileById(mx, id))); + return profiles.filter((profile): profile is PerMessageProfile => profile !== undefined); +} + +export function addOrUpdatePerMessageProfile(mx: MatrixClient, profile: PerMessageProfile) { + const profileListIndex = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.index` as any); + if (profileListIndex?.getContent()?.profileIds.includes(profile.id)) { + // profile already exists, just update it + return mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.${profile.id}` as any, profile as any); + } + // profile doesn't exist, add it to the index and then add the profile data + const newProfileIds = [...(profileListIndex?.getContent()?.profileIds || []), profile.id]; + return Promise.all([ + mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.index` as any, { profileIds: newProfileIds } as any), + mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.${profile.id}` as any, profile as any), + ]); +} + +async function dropIdFromIndex(mx: MatrixClient, id: string) { + const profileListIndex = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.index` as any); + const profileIds = profileListIndex?.getContent()?.profileIds || []; + const newProfileIds = profileIds.filter((profileId: string) => profileId !== id); + await mx.setAccountData( + `${ACCOUNT_DATA_PREFIX}.index` as any, + { profileIds: newProfileIds } as any + ); +} + +async function dropPerMessageProfileRoomAssociations(mx: MatrixClient, id: string) { + const accountData = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any); + const content = accountData?.getContent(); + + const associations: PerMessageProfileRoomAssociation[] = Array.isArray(content) ? content : []; + await mx.setAccountData( + `${ACCOUNT_DATA_PREFIX}.roomassociation` as any, + { + associations: associations.filter((assoc) => assoc.profileId !== id), + } as any + ); +} + +export async function deletePerMessageProfile(mx: MatrixClient, id: string) { + await dropPerMessageProfileRoomAssociations(mx, id); + await mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.${id}` as any, {}); + await dropIdFromIndex(mx, id); +} + +export async function renamePerMessageProfile(mx: MatrixClient, oldId: string, newId: string) { + const profile = await getPerMessageProfileById(mx, oldId); + if (!profile) { + throw new Error('Profile not found'); + } + const newProfile = { ...profile, id: newId }; + await addOrUpdatePerMessageProfile(mx, newProfile); + await deletePerMessageProfile(mx, oldId); +} + +export async function getListOfRoomsUsingProfile( + mx: MatrixClient, + profileId: string +): Promise { + const accountData = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any); + const content = accountData?.getContent()?.associations as + | PerMessageProfileRoomAssociation[] + | undefined; + + if (!Array.isArray(content)) { + // If content is not an array, return empty list + return []; + } + + const roomsUsingProfile = content + .filter( + (assoc: PerMessageProfileRoomAssociation) => + assoc.profileId === profileId && (!assoc.validUntil || assoc.validUntil > Date.now()) + ) + .map((assoc: PerMessageProfileRoomAssociation) => assoc.roomId); + + return roomsUsingProfile; +} + +/** + * 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 any); + const content = accountData?.getContent()?.associations as + | PerMessageProfileRoomAssociation[] + | undefined; + + if (!Array.isArray(content)) { + // If content is not an array, return undefined + return undefined; + } + + const profileId = content + .filter( + (assoc: PerMessageProfileRoomAssociation) => + !assoc.validUntil || assoc.validUntil > Date.now() + ) + .find((assoc: PerMessageProfileRoomAssociation) => assoc.roomId === roomId)?.profileId; + + const pmp = profileId ? await getPerMessageProfileById(mx, profileId) : undefined; + return profileId ? pmp : undefined; +} + +/** + * 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 function setCurrentlyUsedPerMessageProfileIdForRoom( + mx: MatrixClient, + roomId: string, + profileId: string | undefined, + validUntil?: number, + reset?: boolean +) { + const accountData = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any); + const content = accountData?.getContent(); + + const associations: PerMessageProfileRoomAssociation[] = Array.isArray(content) ? content : []; + + if (profileId) { + associations.push({ roomId, profileId, validUntil } satisfies PerMessageProfileRoomAssociation); + } else if (reset) { + associations.filter((assoc) => assoc.roomId !== roomId); + } + + const wrapper: PerMessageProfileRoomAssociationWrapper = { + associations, + }; + return mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any, wrapper as any); +} + +// Atom to trigger refresh +const refreshProfileAtomFamily = atomFamily(() => atom(0)); + +// Atom family to fetch profile for a room +export const perMessageProfileAtomFamily = atomFamily((roomId: string) => + atom(async (get) => { + get(refreshProfileAtomFamily(roomId)); // depend on refresh signal + const mx = useMatrixClient(); // You need to provide this atom for MatrixClient + return getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + }) +); + +/** + * TO-DO + */ +export const invalidatePerMessageProfile: (roomId: string, set: any) => void = ( + roomId: string, + set: any +) => { + set(refreshProfileAtomFamily(roomId), (v: number) => v + 1); +}; + +export function invalidatePerMessageProfileForProfileId( + mx: MatrixClient, + profileId: string, + set: any +) { + getListOfRoomsUsingProfile(mx, profileId).then((roomIds) => { + roomIds.forEach((roomId) => { + set(refreshProfileAtomFamily(roomId), (v: number) => v + 1); + }); + }); +} diff --git a/src/app/utils/pronouns.ts b/src/app/utils/pronouns.ts index c653d9fdc..4af0d3869 100644 --- a/src/app/utils/pronouns.ts +++ b/src/app/utils/pronouns.ts @@ -1,3 +1,9 @@ +export type PronounSet = { + summary: string; + language?: string; + grammatical_gender?: string; +}; + // helper function to convert a comma-separated pronouns string into an array of objects with summary and optional language export function parsePronounsInput(pronouns: string): { summary: string; language?: string }[] { if (!pronouns || typeof pronouns !== 'string') return []; @@ -25,6 +31,10 @@ export function parsePronounsInput(pronouns: string): { summary: string; languag }); } +export function parsePronounsStringToPronounsSetArray(pronouns: string): PronounSet[] { + return parsePronounsInput(pronouns) as unknown as PronounSet[]; +} + // helper function to filter a list of pronouns based on the user's language settings export function filterPronounsByLanguage( pronouns: { summary: string; language?: string }[],