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/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index d1e2601ad..5cf1eef0c 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -21,6 +21,7 @@ import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '$utils/mimeTypes'; import { parseGeoUri, scaleYDimension } from '$utils/common'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { PerMessageProfileBeeperFormat } from '$hooks/usePerMessageProfile'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { FileHeader, FileDownloadButton } from './FileHeader'; import { @@ -109,6 +110,15 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }: const forwardMeta = content['moe.sable.message.forward']; return typeof forwardMeta === 'object'; }, [content]); + + /** + * For the unwrapping of per-message profile fallbacks, we look for tags with the data-mx-profile-fallback attribute + */ + const unwrappedPerMessageProfileMessage = useMemo( + () => customBody?.replace(/]*data-mx-profile-fallback[^>]*>(.*?):\s*<\/strong>/i, ''), + [customBody] + ); + const isJumbo = useMemo(() => { if (!trimmedBody || trimmedBody.length >= 500) return false; if (!JUMBO_EMOJI_REG.test(trimmedBody)) return false; @@ -126,6 +136,19 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }: const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + if ((content['com.beeper.per_message_profile'] as PerMessageProfileBeeperFormat)?.has_fallback) { + // unwrap per-message profile fallback if present + return ( + + {renderBody({ + body: trimmedBody, + customBody: unwrappedPerMessageProfileMessage, + })} + {edited && } + + ); + } + if (isForwarded && unwrappedForwardedContent) { return ( 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 a79eb2beb..3e264c53b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -153,8 +153,13 @@ 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 { sanitizeCustomHtml } from '$utils/sanitize'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { @@ -694,6 +699,42 @@ export const RoomInput = forwardRef( content.format = 'org.matrix.custom.html'; content.formatted_body = formattedBody; } + + /** + * 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 perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + + if (perMessageProfile) { + content['com.beeper.per_message_profile'] = + convertPerMessageProfileToBeeperFormat(perMessageProfile); + + // if a per-message profile is used, it must per spec include a fallback + const prefix = `${perMessageProfile.name}: `; + + if (!content.body.startsWith(prefix)) { + // to prevent double-prefixing when the fallback is already present + content.body = prefix + content.body; + } + + /** + * html escaped version of the display name + */ + const escapedName = sanitizeCustomHtml(perMessageProfile.name); + + const htmlPrefix = `${escapedName}: `; + + 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 we need one + content.format = 'org.matrix.custom.html'; + content.formatted_body = `${htmlPrefix}${plainText}`; + } + } + if (replyDraft) { content['m.relates_to'] = getReplyContent(replyDraft, room); } @@ -794,7 +835,6 @@ export const RoomInput = forwardRef( canSendReaction, mx, roomId, - threadRootId, replyDraft, silentReply, scheduledTime, @@ -802,12 +842,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 0ee9f3397..88a4349ff 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,42 @@ function MessageInternal( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const pmp = useMemo( + /** + * We read the per-message profile from the event content here. + * We have to do this in the message component because the per-message profile can be different for each message, and we need to read it for each message individually. + * We also want to avoid reading and parsing the per-message profile in a parent component like the timeline, because that would be inefficient and would cause unnecessary re-renders of the entire timeline whenever a per-message profile changes. + */ + 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]); + + /** + * boolean to indicate wheather we should indicate to the user that it is a pmp + */ + const showPmPInfo = pmp !== undefined; // 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; + const [highlightMentions] = useSetting(settingsAtom, 'highlightMentions'); // Avatars @@ -431,7 +466,7 @@ function MessageInternal( }, [pmp, senderDisplayName, parsePronouns]); const mergedPronouns = useMemo(() => { - const existing = profile.pronouns ? [...profile.pronouns] : []; + const existing = pronouns ? [...pronouns] : []; if (inlinePronoun) { const isDupe = existing.some((p) => p.summary?.toLowerCase() === inlinePronoun); @@ -445,7 +480,7 @@ function MessageInternal( } return existing; - }, [profile.pronouns, inlinePronoun]); + }, [pronouns, inlinePronoun]); useEffect(() => { if (!mobileOptionsOpen) return undefined; @@ -484,6 +519,26 @@ function MessageInternal( {showPronouns && ( )} + {showPmPInfo && ( + + + + via + + + {senderDisplayName} + + + + )} {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..00a0ff9fe --- /dev/null +++ b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx @@ -0,0 +1,465 @@ +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, + 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 [imageHasChanges, setImageHasChanges] = useState(false); + 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); + setImageHasChanges(true); + }, []); + const handleUploaded = useCallback( + (upload: { status: string; mxc: string }) => { + if (upload?.status === 'success') { + setAvatarMxc(upload.mxc); + if (onChange) onChange({ id: profileId, name: newDisplayName, avatarUrl: upload.mxc }); + setImageHasChanges(true); + } + 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 || + imageHasChanges, + [ + newDisplayName, + currentDisplayName, + newPronounsString, + currentPronounsString, + hasIdChange, + imageHasChanges, + ] + ); + + /** + * handler for resetting the display name + */ + const handleDisplayNameReset = useCallback(() => { + setNewDisplayName(currentDisplayName ?? ''); + }, [currentDisplayName]); + + /** + * handler for resetting the pronouns + */ + const handlePronounsReset = useCallback(() => { + setNewPronouns(currentPronouns); + setNewPronounsString(currentPronounsString); + }, [currentPronouns, currentPronounsString]); + + /** + * persisting the data :3 + */ + const handleSave = useCallback(() => { + addOrUpdatePerMessageProfile(mx, { + id: profileId, + name: newDisplayName, + avatarUrl: avatarMxc, + pronouns: newPronouns, + }).then(() => { + setCurrentDisplayName(newDisplayName); + setCurrentPronouns(newPronouns); + setImageHasChanges(false); + setChangingDisplayName(false); + setDisableSetDisplayname(false); + if (hasIdChange) { + renamePerMessageProfile(mx, profileId, newId).then(() => { + setCurrentId(newId); + }); + } + }); + }, [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..2a0d45666 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -35,6 +35,12 @@ import { sendFeedback } from '$utils/sendFeedbackToUser'; import { useRoomNavigate } from './useRoomNavigate'; import { enrichWidgetUrl } from './useRoomWidgets'; import { useUserProfile } from './useUserProfile'; +import { + addOrUpdatePerMessageProfile, + deletePerMessageProfile, + PerMessageProfile, + setCurrentlyUsedPerMessageProfileIdForRoom, +} from './usePerMessageProfile'; export const SHRUG = String.raw`¯\_(ツ)_/¯`; export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻'; @@ -232,6 +238,9 @@ export enum Command { Font = 'font', SFont = 'sfont', AddWidget = 'addwidget', + AddPerMessageProfileToAccount = 'addpmp', + DeletePerMessageProfileFromAccount = 'delpmp', + UsePerMessageProfile = 'usepmp', Pronoun = 'pronoun', SPronoun = 'spronoun', Rainbow = 'rainbow', @@ -474,6 +483,124 @@ 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() + ); + }) + .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() + ); + }) + .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((e) => { + sendFeedback( + `Failed to reset per message profile for this room. Failed with: "${e.message}"`, + 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() + ); + }) + .catch((e) => { + sendFeedback( + `Failed to set per message profile for this room. Failed with: "${e.message}"`, + 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..8ef2f8d44 --- /dev/null +++ b/src/app/hooks/usePerMessageProfile.ts @@ -0,0 +1,310 @@ +import { PronounSet } from '$utils/pronouns'; +import { MatrixClient } from 'matrix-js-sdk'; + +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[]; + 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: PerMessageProfile +): PerMessageProfileBeeperFormat { + return { + id: profile.id, + displayname: profile.name, + avatar_url: profile.avatarUrl, + 'io.fsky.nyx.pronouns': profile.pronouns, + has_fallback: true, + }; +} + +/** + * 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[]; +}; + +/** + * how we will store room associations in the account data :3 + */ +type PerMessageProfileRoomAssociation = { + profileId: string; + validUntil?: number; +}; + +/** + * 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; +}; + +// Helper to always get a Map from wrapper +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); +} + +export async function getPerMessageProfileById( + mx: MatrixClient, + id: string +): Promise { + const profile = 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 = 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), + ]); +} + +/** + * remove a id from the index of profile ids, used when deleting a profile to + * remove the id from the list of profiles, so it doesn't show up in the list of profiles to manage anymore. + * The actual profile data is also removed when deleting a profile, but this function is only responsible + * for removing the id from the index. + * @param mx the matrix client + * @param id the id to drop from the index + */ +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 getRoomsUsingProfile(mx: MatrixClient, profileId: string): Promise { + const accountData = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any); + 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; +} + +/** + * 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, + profileId: string | undefined, + validUntil?: number, + reset?: boolean +) { + const accountData = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any); + const content: PerMessageProfileRoomAssociationWrapper | undefined = accountData?.getContent(); + const associations = getAssociationsMap(content); + + if (reset) { + associations.delete(roomId); + mx.setAccountData( + `${ACCOUNT_DATA_PREFIX}.roomassociation` as any, + { associations: associationsMapToObject(associations) } as any + ); + return; + } + if (!profileId) { + throw new Error("profile Id is empty, yet it isn't a reset"); + } + associations.set(roomId, { profileId, validUntil }); + mx.setAccountData( + `${ACCOUNT_DATA_PREFIX}.roomassociation` as any, + { associations: associationsMapToObject(associations) } as any + ); +} + +/** + * 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 any); + 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 any, + { associations: associationsMapToObject(associations) } as any + ); +} + +/** + * 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); + await mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.${id}` as any, {}); + await dropIdFromIndex(mx, 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 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 { + return getRoomsUsingProfile(mx, profileId); +} + +/** + * 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: 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; +} diff --git a/src/app/utils/pronouns.ts b/src/app/utils/pronouns.ts index 6d111a2a1..943547a0b 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 }[],