From ed689792ee6d71a47bdd787ab5fb09c523dfe88c Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 16 Mar 2026 17:44:30 +0100 Subject: [PATCH 01/33] implement per message profile management functions --- src/app/hooks/usePerMessageProfile.ts | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/app/hooks/usePerMessageProfile.ts diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts new file mode 100644 index 000000000..394a42c34 --- /dev/null +++ b/src/app/hooks/usePerMessageProfile.ts @@ -0,0 +1,65 @@ +import { MatrixClient } from 'matrix-js-sdk'; + +/** + * 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; +}; + +type PerMessageProfileIndex = { + /** + * a list of all profile ids, used to list all profiles when the user wants to manage them. + */ + profileIds: string[]; +}; + +export function getPerMessageProfileById( + mx: MatrixClient, + id: string +): PerMessageProfile | undefined { + const profile = mx.getAccountData(`fyi.cisnt.permessageprofile.${id}` as any); + return profile ? (profile as unknown as PerMessageProfile) : undefined; +} + +export function getAllPerMessageProfiles(mx: MatrixClient): PerMessageProfile[] { + const profileData = mx.getAccountData('fyi.cisnt.permessageprofile.index' as any); + const profileIds = (profileData?.getContent() as PerMessageProfileIndex)?.profileIds || []; + return profileIds + .map((id) => getPerMessageProfileById(mx, id)) + .filter((profile): profile is PerMessageProfile => profile !== undefined); +} + +export function addOrUpdatePerMessageProfile(mx: MatrixClient, profile: PerMessageProfile) { + const profileListIndex = mx.getAccountData('fyi.cisnt.permessageprofile.index' as any); + if (profileListIndex?.getContent()?.profileIds.includes(profile.id)) { + // profile already exists, just update it + return mx.setAccountData(`fyi.cisnt.permessageprofile.${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( + 'fyi.cisnt.permessageprofile.index' as any, + { profileIds: newProfileIds } as any + ), + mx.setAccountData(`fyi.cisnt.permessageprofile.${profile.id}` as any, profile as any), + ]); +} + +export function deletePerMessageProfile(mx: MatrixClient, id: string) { + return mx.setAccountData(`fyi.cisnt.permessageprofile.${id}` as any, {}); +} From 2a83578ad726fda136b428cd303c90e784d1067a Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 16 Mar 2026 18:44:41 +0100 Subject: [PATCH 02/33] add per message profile management commands and functionality --- src/app/hooks/useCommands.ts | 81 +++++++++++++++++++++++++++ src/app/hooks/usePerMessageProfile.ts | 81 +++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 0c2008c01..a9a4cfd79 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -35,6 +35,12 @@ import { parsePronounsInput } from '$utils/pronouns'; import { useRoomNavigate } from './useRoomNavigate'; import { enrichWidgetUrl } from './useRoomWidgets'; import { useUserProfile } from './useUserProfile'; +import { + addOrUpdatePerMessageProfile, + deletePerMessageProfile, + PerMessageProfile, + setCurrentlyUsedPerMessageProfileIdForRoom, +} from './usePerMessageProfile'; export const SHRUG = '¯\\_(ツ)_/¯'; 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', @@ -471,6 +480,78 @@ 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 avatarUrl=mxc://xyzabc', + exe: async (payload) => { + const [profileId, ...profileParts] = splitWithSpace(payload); + if (profileId === 'index') { + // "index" is reserved for the profile index, reject it as a profile id + return; + } + const pmp: PerMessageProfile = { + id: profileId, + name: profileParts.find((p) => p.startsWith('name='))?.substring(5) || '', + avatarUrl: profileParts.find((p) => p.startsWith('avatarUrl='))?.substring(10), + }; + await addOrUpdatePerMessageProfile(mx, pmp); + }, + }, + [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 + return; + } + await deletePerMessageProfile(mx, profileId); + }, + }, + [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; + const sendFeedback = (msg: string) => { + const localNotice = new MatrixEvent({ + type: 'm.room.message', + content: { msgtype: 'm.notice', body: msg }, + event_id: `~nullptr-widget-${Date.now()}`, + room_id: room.roomId, + sender: mx.getSafeUserId(), + }); + (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); + }; + if (durationStr === 'reset') { + setCurrentlyUsedPerMessageProfileIdForRoom(mx, room.roomId, undefined, undefined, true) + .then(() => { + sendFeedback('Per message profile reset for this room.'); + }) + .catch(() => { + sendFeedback('Failed to reset per message profile for this room.'); + }); + 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' + }.` + ); + }) + .catch(() => { + sendFeedback('Failed to set per message profile for this room.'); + }); + }, + }, [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 index 394a42c34..678186748 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -27,6 +27,23 @@ type PerMessageProfileIndex = { 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. +}; + +type PerMessageProfileRoomAssociationWrapper = { + associations: PerMessageProfileRoomAssociation[]; +}; + export function getPerMessageProfileById( mx: MatrixClient, id: string @@ -63,3 +80,67 @@ export function addOrUpdatePerMessageProfile(mx: MatrixClient, profile: PerMessa export function deletePerMessageProfile(mx: MatrixClient, id: string) { return mx.setAccountData(`fyi.cisnt.permessageprofile.${id}` as any, {}); } + +/** + * 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 function getCurrentlyUsedPerMessageProfileIdForRoom( + mx: MatrixClient, + roomId: string +): PerMessageProfile | undefined { + const accountData = mx.getAccountData(`fyi.cisnt.permessageprofile.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; + + console.warn('getCurrentlyUsedPerMessageProfileIdForRoom', { profileId, roomId }); + return profileId ? getPerMessageProfileById(mx, profileId) : 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(`fyi.cisnt.permessageprofile.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(`fyi.cisnt.permessageprofile.roomassociation` as any, wrapper as any); +} From 1680ab9872eea6465002d9297d7bb48ff4f48b9d Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 16 Mar 2026 18:56:22 +0100 Subject: [PATCH 03/33] integrate per-message profile fetching in RoomInput component --- src/app/features/room/RoomInput.tsx | 28 +++++++++++++++++++++-- src/app/hooks/usePerMessageProfile.ts | 32 ++++++++++++++++----------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 1b0e6f5f5..2773dc77c 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -151,6 +151,7 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; +import { getCurrentlyUsedPerMessageProfileForRoom } from '$hooks/usePerMessageProfile'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { @@ -269,6 +270,12 @@ export const RoomInput = forwardRef( room ); + const permessageprofile = useCallback(async () => { + const profile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + console.debug('Fetched per-message profile:', { profile, roomId }); + return profile; + }, [mx, roomId]); + const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey)); const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( @@ -624,11 +631,27 @@ export const RoomInput = forwardRef( const formattedBody = customHtml; const mentionData = getMentions(mx, roomId, editor); + const perMessageProfile = await permessageprofile(); + const content: IContent = { msgtype: msgType, body, }; + if (perMessageProfile) { + console.warn( + 'Using per-message profile:', + perMessageProfile.id, + perMessageProfile.name, + perMessageProfile.avatarUrl + ); + content['com.beeper.per_message_profile'] = { + id: perMessageProfile.id, + displayname: perMessageProfile.name, + avatar_url: perMessageProfile.avatarUrl, + }; + } + if (replyDraft && !silentReply) { mentionData.users.add(replyDraft.userId); } @@ -723,7 +746,7 @@ export const RoomInput = forwardRef( canSendReaction, mx, roomId, - threadRootId, + permessageprofile, replyDraft, silentReply, scheduledTime, @@ -731,12 +754,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/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 678186748..8107940a5 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -44,20 +44,19 @@ type PerMessageProfileRoomAssociationWrapper = { associations: PerMessageProfileRoomAssociation[]; }; -export function getPerMessageProfileById( +export async function getPerMessageProfileById( mx: MatrixClient, id: string -): PerMessageProfile | undefined { - const profile = mx.getAccountData(`fyi.cisnt.permessageprofile.${id}` as any); - return profile ? (profile as unknown as PerMessageProfile) : undefined; +): Promise { + const profile = await mx.getAccountData(`fyi.cisnt.permessageprofile.${id}` as any); + return profile ? (profile.getContent() as unknown as PerMessageProfile) : undefined; } -export function getAllPerMessageProfiles(mx: MatrixClient): PerMessageProfile[] { - const profileData = mx.getAccountData('fyi.cisnt.permessageprofile.index' as any); +export async function getAllPerMessageProfiles(mx: MatrixClient): Promise { + const profileData = await mx.getAccountData('fyi.cisnt.permessageprofile.index' as any); const profileIds = (profileData?.getContent() as PerMessageProfileIndex)?.profileIds || []; - return profileIds - .map((id) => getPerMessageProfileById(mx, id)) - .filter((profile): profile is PerMessageProfile => profile !== undefined); + 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) { @@ -87,10 +86,10 @@ export function deletePerMessageProfile(mx: MatrixClient, id: string) { * @param roomId the room id you are querying for * @returns the profile to be used */ -export function getCurrentlyUsedPerMessageProfileIdForRoom( +export async function getCurrentlyUsedPerMessageProfileForRoom( mx: MatrixClient, roomId: string -): PerMessageProfile | undefined { +): Promise { const accountData = mx.getAccountData(`fyi.cisnt.permessageprofile.roomassociation` as any); const content = accountData?.getContent()?.associations as | PerMessageProfileRoomAssociation[] @@ -108,8 +107,15 @@ export function getCurrentlyUsedPerMessageProfileIdForRoom( ) .find((assoc: PerMessageProfileRoomAssociation) => assoc.roomId === roomId)?.profileId; - console.warn('getCurrentlyUsedPerMessageProfileIdForRoom', { profileId, roomId }); - return profileId ? getPerMessageProfileById(mx, profileId) : undefined; + const pmp = profileId ? await getPerMessageProfileById(mx, profileId) : undefined; + console.warn('getCurrentlyUsedPerMessageProfileIdForRoom', { + accountData, + content, + roomId, + profileId, + pmp, + }); + return profileId ? pmp : undefined; } /** From 4250f240e34ebca033e19770dbb7f2392949654b Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 16 Mar 2026 21:55:41 +0100 Subject: [PATCH 04/33] enhance per message profile command to support key=value parsing for name and avatar --- src/app/hooks/useCommands.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index a9a4cfd79..e4ccbfea3 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -483,17 +483,28 @@ 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 avatarUrl=mxc://xyzabc', + 'Add or update a per message profile to your account. Example: /addpmp profileId name=Profile Name avatar=mxc://xyzabc', exe: async (payload) => { - const [profileId, ...profileParts] = splitWithSpace(payload); - if (profileId === 'index') { - // "index" is reserved for the profile index, reject it as a profile id - return; - } + // Parse key=value pairs + const parts = payload.split(' '); + let avatarUrl: string | undefined; + let name: string | undefined; + parts.forEach((part) => { + const [key, value] = part.split('='); + if (key && value) { + if (key === 'name' || key === 'avatar') { + if (key === 'name') name = value; + if (key === 'avatar') avatarUrl = value; + } + } + }); + + const profileId = parts[0]; // profileId is positional (before any key=) + const pmp: PerMessageProfile = { id: profileId, - name: profileParts.find((p) => p.startsWith('name='))?.substring(5) || '', - avatarUrl: profileParts.find((p) => p.startsWith('avatarUrl='))?.substring(10), + name: name || '', + avatarUrl, }; await addOrUpdatePerMessageProfile(mx, pmp); }, From b17293e4fc60add3902c3a1d539dcddea0235948 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 16 Mar 2026 22:03:05 +0100 Subject: [PATCH 05/33] enhance per message profile commands with feedback on success and failure --- src/app/hooks/useCommands.ts | 61 ++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index b03c1778b..6b14c44bc 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -509,7 +509,21 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { name: name || '', avatarUrl, }; - await addOrUpdatePerMessageProfile(mx, pmp); + 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]: { @@ -519,9 +533,24 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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); + 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]: { @@ -533,23 +562,17 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const profileId: string = splitWithSpace(payload)[0]; const durationStr: string | undefined = splitWithSpace(payload)[1]; let validUntil: number | undefined; - const sendFeedback = (msg: string) => { - const localNotice = new MatrixEvent({ - type: 'm.room.message', - content: { msgtype: 'm.notice', body: msg }, - event_id: `~nullptr-widget-${Date.now()}`, - room_id: room.roomId, - sender: mx.getSafeUserId(), - }); - (room as any).addLiveEvents([localNotice], { duplicateStrategy: 'ignore' } as any); - }; if (durationStr === 'reset') { setCurrentlyUsedPerMessageProfileIdForRoom(mx, room.roomId, undefined, undefined, true) .then(() => { - sendFeedback('Per message profile reset for this room.'); + sendFeedback('Per message profile reset for this room.', room, mx.getSafeUserId()); }) .catch(() => { - sendFeedback('Failed to reset per message profile for this room.'); + sendFeedback( + 'Failed to reset per message profile for this room.', + room, + mx.getSafeUserId() + ); }); return; } @@ -558,11 +581,17 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { 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(() => { - sendFeedback('Failed to set per message profile for this room.'); + sendFeedback( + 'Failed to set per message profile for this room.', + room, + mx.getSafeUserId() + ); }); }, }, From 9348a8ada8319b46744f901066c219a086e00e72 Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 16 Mar 2026 22:27:01 +0100 Subject: [PATCH 06/33] enhance RoomInput to cache per-message profile and improve fetching logic; update useCommands to support multi-word names in key=value parsing --- src/app/features/room/RoomInput.tsx | 25 +++++++++++++++++-------- src/app/hooks/useCommands.ts | 10 ++++++++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index a137eee3f..39cbe6d6b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -151,7 +151,10 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; -import { getCurrentlyUsedPerMessageProfileForRoom } from '$hooks/usePerMessageProfile'; +import { + getCurrentlyUsedPerMessageProfileForRoom, + PerMessageProfile, +} from '$hooks/usePerMessageProfile'; import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; @@ -271,10 +274,18 @@ export const RoomInput = forwardRef( room ); - const permessageprofile = useCallback(async () => { - const profile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - console.debug('Fetched per-message profile:', { profile, roomId }); - return profile; + // Add state to cache the profile + const [perMessageProfile, setPerMessageProfile] = useState(null); + + // Fetch and cache the profile when the roomId changes + useEffect(() => { + const fetchPmP = async () => { + const profile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + console.debug('Fetched per-message profile:', { profile, roomId }); + setPerMessageProfile(profile ?? null); // Convert undefined to null + }; + + fetchPmP(); }, [mx, roomId]); const [uploadBoard, setUploadBoard] = useState(true); @@ -632,8 +643,6 @@ export const RoomInput = forwardRef( const formattedBody = customHtml; const mentionData = getMentions(mx, roomId, editor); - const perMessageProfile = await permessageprofile(); - const content: IContent = { msgtype: msgType, body, @@ -747,7 +756,7 @@ export const RoomInput = forwardRef( canSendReaction, mx, roomId, - permessageprofile, + perMessageProfile, replyDraft, silentReply, scheduledTime, diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 6b14c44bc..98314ff24 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -492,11 +492,17 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { const parts = payload.split(' '); let avatarUrl: string | undefined; let name: string | undefined; - parts.forEach((part) => { + parts.forEach((part, index) => { const [key, value] = part.split('='); if (key && value) { if (key === 'name' || key === 'avatar') { - if (key === 'name') name = value; + if (key === 'name') { + name = parts + .slice(index) + .map((p) => p.split('=')[1]) + .join(' '); + return; + } if (key === 'avatar') avatarUrl = value; } } From 6a0c2e3aa1de33d147e8fcc28a4e13adf11bc2ea Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 16 Mar 2026 23:32:29 +0100 Subject: [PATCH 07/33] add PerMessageProfile editor and overview components; integrate into settings page --- .../PerMessageProfileEditor.tsx | 190 ++++++++++++++++++ .../PerMessageProfileOverview.tsx | 35 ++++ .../PerMessageProfilesPage.tsx | 31 +++ src/app/features/settings/Settings.tsx | 10 + 4 files changed, 266 insertions(+) create mode 100644 src/app/features/settings/PerMessageProfiles/PerMessageProfileEditor.tsx create mode 100644 src/app/features/settings/PerMessageProfiles/PerMessageProfileOverview.tsx create mode 100644 src/app/features/settings/PerMessageProfiles/PerMessageProfilesPage.tsx diff --git a/src/app/features/settings/PerMessageProfiles/PerMessageProfileEditor.tsx b/src/app/features/settings/PerMessageProfiles/PerMessageProfileEditor.tsx new file mode 100644 index 000000000..2672f5276 --- /dev/null +++ b/src/app/features/settings/PerMessageProfiles/PerMessageProfileEditor.tsx @@ -0,0 +1,190 @@ +import { SequenceCard } from '$components/sequence-card'; +import { Box, Button, Text, Avatar, config, Icon, IconButton, Icons, Input } from 'folds'; +// Try relative import for CompactUploadCardRenderer +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 { 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; + onChange?: (profile: { id: string; name: string; avatarUrl?: string }) => void; +}; + +export function PerMessageProfileEditor({ + mx, + profileId, + avatarMxcUrl, + displayName, + onChange, +}: Readonly) { + const useAuthentication = useMediaAuthentication(); + const [newDisplayName, setNewDisplayName] = useState(displayName ?? ''); + const [imageFile, setImageFile] = useState(); + const imageFileURL = useObjectURL(imageFile); + const avatarUrl = useMemo(() => { + if (imageFileURL) return imageFileURL; + if (avatarMxcUrl) { + return mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 96, 96, 'crop') ?? undefined; + } + return undefined; + }, [imageFileURL, avatarMxcUrl, 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' && onChange) { + onChange({ id: profileId, name: newDisplayName, avatarUrl: upload.mxc }); + } + setImageFile(undefined); + }, + [onChange, profileId, newDisplayName] + ); + const handleNameChange = useCallback((e: React.ChangeEvent) => { + setNewDisplayName(e.target.value); + }, []); + + // Added missing state and logic for display name editing + const [changingDisplayName, setChangingDisplayName] = useState(false); + const [disableSetDisplayname, setDisableSetDisplayname] = useState(false); + + // Determine if there are changes to the display name or avatar + const hasChanges = useMemo( + () => newDisplayName !== (displayName ?? '') || !!imageFile, + [newDisplayName, displayName, imageFile] + ); + + // Reset handler for display name + const handleReset = useCallback(() => { + setNewDisplayName(displayName ?? ''); + setChangingDisplayName(false); + setDisableSetDisplayname(false); + }, [displayName]); + + return ( + + + + + + + p} + /> + + + + + + {uploadAtom ? ( + + + + ) : ( + + + + + ) + } + /> + + )} + + + + + ); +} diff --git a/src/app/features/settings/PerMessageProfiles/PerMessageProfileOverview.tsx b/src/app/features/settings/PerMessageProfiles/PerMessageProfileOverview.tsx new file mode 100644 index 000000000..d90e20734 --- /dev/null +++ b/src/app/features/settings/PerMessageProfiles/PerMessageProfileOverview.tsx @@ -0,0 +1,35 @@ +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { getAllPerMessageProfiles, PerMessageProfile } from '$hooks/usePerMessageProfile'; +import { useEffect, useState } from 'react'; +import { Box } 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]); + + return ( + + {profiles.map((profile) => ( + + ))} + + ); +} diff --git a/src/app/features/settings/PerMessageProfiles/PerMessageProfilesPage.tsx b/src/app/features/settings/PerMessageProfiles/PerMessageProfilesPage.tsx new file mode 100644 index 000000000..55ff3c28c --- /dev/null +++ b/src/app/features/settings/PerMessageProfiles/PerMessageProfilesPage.tsx @@ -0,0 +1,31 @@ +import { Page, PageHeader } 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 ( + + + + + + Per Message Profiles + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 34f350b3d..139af2b95 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 './PerMessageProfiles/PerMessageProfilesPage'; 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: 'Per-Message Profiles', + 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 && ( )} From cd1e87a9dcc859140770317597d96d0a06b20e9b Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 15:18:08 +0100 Subject: [PATCH 08/33] restructure per-message profile components and update settings integration --- .../PerMessageProfileEditor.tsx | 190 ----------- .../Profiles/PerMessageProfileEditor.tsx | 310 ++++++++++++++++++ .../PerMessageProfileOverview.tsx | 2 +- .../ProfilesPage.tsx} | 25 +- src/app/features/settings/Settings.tsx | 4 +- 5 files changed, 333 insertions(+), 198 deletions(-) delete mode 100644 src/app/features/settings/PerMessageProfiles/PerMessageProfileEditor.tsx create mode 100644 src/app/features/settings/Profiles/PerMessageProfileEditor.tsx rename src/app/features/settings/{PerMessageProfiles => Profiles}/PerMessageProfileOverview.tsx (94%) rename src/app/features/settings/{PerMessageProfiles/PerMessageProfilesPage.tsx => Profiles/ProfilesPage.tsx} (56%) diff --git a/src/app/features/settings/PerMessageProfiles/PerMessageProfileEditor.tsx b/src/app/features/settings/PerMessageProfiles/PerMessageProfileEditor.tsx deleted file mode 100644 index 2672f5276..000000000 --- a/src/app/features/settings/PerMessageProfiles/PerMessageProfileEditor.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { SequenceCard } from '$components/sequence-card'; -import { Box, Button, Text, Avatar, config, Icon, IconButton, Icons, Input } from 'folds'; -// Try relative import for CompactUploadCardRenderer -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 { 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; - onChange?: (profile: { id: string; name: string; avatarUrl?: string }) => void; -}; - -export function PerMessageProfileEditor({ - mx, - profileId, - avatarMxcUrl, - displayName, - onChange, -}: Readonly) { - const useAuthentication = useMediaAuthentication(); - const [newDisplayName, setNewDisplayName] = useState(displayName ?? ''); - const [imageFile, setImageFile] = useState(); - const imageFileURL = useObjectURL(imageFile); - const avatarUrl = useMemo(() => { - if (imageFileURL) return imageFileURL; - if (avatarMxcUrl) { - return mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 96, 96, 'crop') ?? undefined; - } - return undefined; - }, [imageFileURL, avatarMxcUrl, 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' && onChange) { - onChange({ id: profileId, name: newDisplayName, avatarUrl: upload.mxc }); - } - setImageFile(undefined); - }, - [onChange, profileId, newDisplayName] - ); - const handleNameChange = useCallback((e: React.ChangeEvent) => { - setNewDisplayName(e.target.value); - }, []); - - // Added missing state and logic for display name editing - const [changingDisplayName, setChangingDisplayName] = useState(false); - const [disableSetDisplayname, setDisableSetDisplayname] = useState(false); - - // Determine if there are changes to the display name or avatar - const hasChanges = useMemo( - () => newDisplayName !== (displayName ?? '') || !!imageFile, - [newDisplayName, displayName, imageFile] - ); - - // Reset handler for display name - const handleReset = useCallback(() => { - setNewDisplayName(displayName ?? ''); - setChangingDisplayName(false); - setDisableSetDisplayname(false); - }, [displayName]); - - return ( - - - - - - - p} - /> - - - - - - {uploadAtom ? ( - - - - ) : ( - - - - - ) - } - /> - - )} - - - - - ); -} diff --git a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx new file mode 100644 index 000000000..c91328a59 --- /dev/null +++ b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx @@ -0,0 +1,310 @@ +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 } from '$hooks/usePerMessageProfile'; +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; + onChange?: (profile: { id: string; name: string; avatarUrl?: string }) => void; +}; + +export function PerMessageProfileEditor({ + mx, + profileId, + avatarMxcUrl, + displayName, + onChange, +}: Readonly) { + const useAuthentication = useMediaAuthentication(); + const [currentDisplayName, setCurrentDisplayName] = useState(displayName ?? ''); + 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 hasChanges = useMemo( + () => newDisplayName !== (currentDisplayName ?? '') || !!imageFile, + [newDisplayName, currentDisplayName, imageFile] + ); + + // Reset handler for display name + const handleReset = useCallback(() => { + setNewDisplayName(currentDisplayName); + setChangingDisplayName(false); + setDisableSetDisplayname(false); + }, [currentDisplayName]); + + const handleSave = useCallback(() => { + addOrUpdatePerMessageProfile(mx, { + id: profileId, + name: newDisplayName, + avatarUrl: avatarMxc, + }).then(() => { + setCurrentDisplayName(newDisplayName); + }); + setChangingDisplayName(false); + setDisableSetDisplayname(false); + }, [mx, profileId, newDisplayName, avatarMxc]); + + const handleDelete = useCallback(() => { + deletePerMessageProfile(mx, profileId).then(() => { + setCurrentDisplayName(''); + }); + }, [mx, profileId]); + + return ( + + + + Profile ID: {profileId} + + {/* Linke Spalte: Avatar + Upload */} + + + ( + + p + + )} + alt={`Avatar for profile ${profileId}`} + /> + + + {/* Upload-Bereich falls aktiv */} + {uploadAtom && ( + + + + )} + + {/* Mittlere Spalte: Display Name Input */} + + + + + + ) + } + /> + + {/* Rechte Spalte: Save Button */} + + + + + + + ); +} diff --git a/src/app/features/settings/PerMessageProfiles/PerMessageProfileOverview.tsx b/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx similarity index 94% rename from src/app/features/settings/PerMessageProfiles/PerMessageProfileOverview.tsx rename to src/app/features/settings/Profiles/PerMessageProfileOverview.tsx index d90e20734..879a6dbc2 100644 --- a/src/app/features/settings/PerMessageProfiles/PerMessageProfileOverview.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx @@ -21,7 +21,7 @@ export function PerMessageProfileOverview() { }, [mx]); return ( - + {profiles.map((profile) => ( - Per Message Profiles + Profiles (Per-Message) @@ -23,9 +23,24 @@ export function PerMessageProfilePage({ requestClose }: PerMessageProfilePagePro - - - + + + Per-Message Profiles + + + ); } diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 139af2b95..55fde6137 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -36,7 +36,7 @@ import { General } from './general'; import { Cosmetics } from './cosmetics/Cosmetics'; import { Experimental } from './experimental/Experimental'; import { KeyboardShortcuts } from './keyboard-shortcuts'; -import { PerMessageProfilePage } from './PerMessageProfiles/PerMessageProfilesPage'; +import { PerMessageProfilePage } from './Profiles/ProfilesPage'; export enum SettingsPages { GeneralPage, @@ -74,7 +74,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] => }, { page: SettingsPages.PerMessageProfilesPage, - name: 'Per-Message Profiles', + name: 'Profiles', icon: Icons.User, }, { From d279c4029b85c47b0676a292012f9be8a8ff106f Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 15:48:59 +0100 Subject: [PATCH 09/33] add delete functionality to PerMessageProfileEditor and integrate into overview; adjust layout and styles --- .../Profiles/PerMessageProfileEditor.tsx | 46 ++++++++++--------- .../Profiles/PerMessageProfileOverview.tsx | 33 +++++++++++-- .../settings/Profiles/ProfilesPage.tsx | 1 - 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx index c91328a59..089997851 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx @@ -21,6 +21,7 @@ type PerMessageProfileEditorProps = { avatarMxcUrl?: string; displayName?: string; onChange?: (profile: { id: string; name: string; avatarUrl?: string }) => void; + onDelete?: (profileId: string) => void; }; export function PerMessageProfileEditor({ @@ -29,6 +30,7 @@ export function PerMessageProfileEditor({ avatarMxcUrl, displayName, onChange, + onDelete, }: Readonly) { const useAuthentication = useMediaAuthentication(); const [currentDisplayName, setCurrentDisplayName] = useState(displayName ?? ''); @@ -97,6 +99,7 @@ export function PerMessageProfileEditor({ const handleDelete = useCallback(() => { deletePerMessageProfile(mx, profileId).then(() => { setCurrentDisplayName(''); + if (onDelete) onDelete(profileId); }); }, [mx, profileId]); @@ -124,8 +127,8 @@ export function PerMessageProfileEditor({ style={{ width: '100%', minWidth: 500, - minHeight: 200, - padding: config.space.S600, + minHeight: 100, + maxHeight: 200, boxSizing: 'border-box', display: 'flex', flexDirection: 'row', @@ -136,27 +139,26 @@ export function PerMessageProfileEditor({ }} > Profile ID: {profileId} - {/* Linke Spalte: Avatar + Upload */} Upload - {/* Upload-Bereich falls aktiv */} {uploadAtom && ( )} - {/* Mittlere Spalte: Display Name Input */} @@ -235,14 +235,15 @@ export function PerMessageProfileEditor({ flex: 1, minWidth: 0, width: '100%', - maxWidth: 'clamp(120px, 40vw, 320px)', + maxWidth: 'clamp(200px, 60vw, 480px)', paddingRight: config.space.S200, fontSize: 16, - height: 36, + height: 50, }} placeholder="Display name" readOnly={changingDisplayName || disableSetDisplayname} - aria-label="Display name" + aria-label={`Display name for ${profileId}`} + title={`Display name for ${profileId}`} after={ hasChanges && !changingDisplayName && ( @@ -253,6 +254,7 @@ export function PerMessageProfileEditor({ radii="300" variant="Secondary" aria-label="Reset display name" + title="Reset display name" > @@ -266,7 +268,7 @@ export function PerMessageProfileEditor({ alignItems="Center" justifyContent="Center" style={{ minWidth: 120, maxWidth: 140, flexShrink: 0, height: '100%' }} - aria-label="Save button area" + aria-label={`Save button area for ${profileId}`} > @@ -293,13 +296,14 @@ export function PerMessageProfileEditor({ fill="None" style={{ minWidth: 120, - height: 44, + height: 'clamp(30px, 6vw, 50px)', marginTop: config.space.S100, display: 'flex', alignItems: 'center', justifyContent: 'center', }} - aria-label="Delete profile" + aria-label={`Delete profile ${profileId}`} + title={`Delete profile ${profileId}`} > Delete diff --git a/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx b/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx index 879a6dbc2..5f4a20615 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx @@ -1,7 +1,11 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; -import { getAllPerMessageProfiles, PerMessageProfile } from '$hooks/usePerMessageProfile'; +import { + addOrUpdatePerMessageProfile, + getAllPerMessageProfiles, + PerMessageProfile, +} from '$hooks/usePerMessageProfile'; import { useEffect, useState } from 'react'; -import { Box } from 'folds'; +import { Box, Button, Text } from 'folds'; import { PerMessageProfileEditor } from './PerMessageProfileEditor'; /** @@ -20,14 +24,37 @@ export function PerMessageProfileOverview() { 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/Profiles/ProfilesPage.tsx b/src/app/features/settings/Profiles/ProfilesPage.tsx index 56eff745e..3b26cd8dd 100644 --- a/src/app/features/settings/Profiles/ProfilesPage.tsx +++ b/src/app/features/settings/Profiles/ProfilesPage.tsx @@ -37,7 +37,6 @@ export function PerMessageProfilePage({ requestClose }: PerMessageProfilePagePro direction="Column" shrink="No" > - Per-Message Profiles From 26bc3744bcd7a709c0c65e299c2c851fd239296e Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 16:24:46 +0100 Subject: [PATCH 10/33] add pronouns support to PerMessageProfile; update editor and hooks for pronoun management --- src/app/features/room/RoomInput.tsx | 1 + .../Profiles/PerMessageProfileEditor.tsx | 95 +++++++++++++++++-- .../settings/account/PronounEditor.tsx | 8 +- src/app/hooks/usePerMessageProfile.ts | 23 ++--- src/app/utils/pronouns.ts | 10 ++ 5 files changed, 113 insertions(+), 24 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 39cbe6d6b..5d4d11f4b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -659,6 +659,7 @@ export const RoomInput = forwardRef( id: perMessageProfile.id, displayname: perMessageProfile.name, avatar_url: perMessageProfile.avatarUrl, + 'io.fsky.nyx.pronouns': perMessageProfile.pronouns, }; } diff --git a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx index 089997851..616820659 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx @@ -11,6 +11,7 @@ import { UserAvatar } from '$components/user-avatar'; import { CompactUploadCardRenderer } from '$components/upload-card'; import { addOrUpdatePerMessageProfile, deletePerMessageProfile } from '$hooks/usePerMessageProfile'; import { SequenceCardStyle } from '../styles.css'; +import { parsePronounsStringToPronounsSetArray, PronounSet } from '$utils/pronouns'; /** * 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. @@ -20,6 +21,7 @@ type PerMessageProfileEditorProps = { profileId: string; avatarMxcUrl?: string; displayName?: string; + pronouns?: PronounSet[]; onChange?: (profile: { id: string; name: string; avatarUrl?: string }) => void; onDelete?: (profileId: string) => void; }; @@ -29,11 +31,29 @@ export function PerMessageProfileEditor({ profileId, avatarMxcUrl, displayName, + pronouns = Array(), onChange, onDelete, }: Readonly) { const useAuthentication = useMediaAuthentication(); const [currentDisplayName, setCurrentDisplayName] = useState(displayName ?? ''); + + // Pronouns + const [currentPronouns, setCurrentPronouns] = useState(pronouns); + const currentPronounsString = useMemo(() => { + const pronounsString = Array.isArray(currentPronouns) + ? currentPronouns.map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`).join(', ') + : ''; + return [pronounsString] as const; + }, [currentPronouns]); + const [newPronouns, setNewPronouns] = useState(pronouns); + const newPronounsString = useMemo(() => { + const pronounsString = Array.isArray(newPronouns) + ? newPronouns.map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`).join(', ') + : ''; + return pronounsString; + }, [newPronouns]); + const [newDisplayName, setNewDisplayName] = useState(currentDisplayName); const [imageFile, setImageFile] = useState(); const [avatarMxc, setAvatarMxc] = useState(avatarMxcUrl); @@ -73,16 +93,20 @@ export function PerMessageProfileEditor({ const [disableSetDisplayname, setDisableSetDisplayname] = useState(false); const hasChanges = useMemo( - () => newDisplayName !== (currentDisplayName ?? '') || !!imageFile, - [newDisplayName, currentDisplayName, imageFile] + () => + newDisplayName !== (currentDisplayName ?? '') || + newPronouns !== (currentPronouns ?? '') || + !!imageFile, + [newDisplayName, currentDisplayName, newPronouns, currentPronouns, imageFile] ); // Reset handler for display name const handleReset = useCallback(() => { setNewDisplayName(currentDisplayName); + setNewPronouns(currentPronouns); setChangingDisplayName(false); setDisableSetDisplayname(false); - }, [currentDisplayName]); + }, [currentDisplayName, currentPronouns]); const handleSave = useCallback(() => { addOrUpdatePerMessageProfile(mx, { @@ -91,17 +115,23 @@ export function PerMessageProfileEditor({ avatarUrl: avatarMxc, }).then(() => { setCurrentDisplayName(newDisplayName); + setCurrentPronouns(newPronouns); }); setChangingDisplayName(false); setDisableSetDisplayname(false); - }, [mx, profileId, newDisplayName, avatarMxc]); + }, [mx, profileId, newDisplayName, avatarMxc, newPronouns]); const handleDelete = useCallback(() => { deletePerMessageProfile(mx, profileId).then(() => { setCurrentDisplayName(''); + setCurrentPronouns([]); if (onDelete) onDelete(profileId); }); - }, [mx, profileId]); + }, [mx, profileId, onDelete]); + + const handlePronounsChange = useCallback(() => { + return setNewPronouns(parsePronounsStringToPronounsSetArray(newPronounsString)); + }, [newPronounsString]); return ( + + + + + ) + } + /> {/* Rechte Spalte: Save Button */} { - const profile = await mx.getAccountData(`fyi.cisnt.permessageprofile.${id}` as any); + 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('fyi.cisnt.permessageprofile.index' as any); + 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('fyi.cisnt.permessageprofile.index' as any); + 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(`fyi.cisnt.permessageprofile.${profile.id}` as any, profile as any); + 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( - 'fyi.cisnt.permessageprofile.index' as any, - { profileIds: newProfileIds } as any - ), - mx.setAccountData(`fyi.cisnt.permessageprofile.${profile.id}` as any, profile as any), + mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.index` as any, { profileIds: newProfileIds } as any), + mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.${profile.id}` as any, profile as any), ]); } export function deletePerMessageProfile(mx: MatrixClient, id: string) { - return mx.setAccountData(`fyi.cisnt.permessageprofile.${id}` as any, {}); + return mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.${id}` as any, {}); } /** @@ -90,7 +91,7 @@ export async function getCurrentlyUsedPerMessageProfileForRoom( mx: MatrixClient, roomId: string ): Promise { - const accountData = mx.getAccountData(`fyi.cisnt.permessageprofile.roomassociation` as any); + const accountData = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any); const content = accountData?.getContent()?.associations as | PerMessageProfileRoomAssociation[] | undefined; 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 }[], From 0ef60ca4d2077c98b8e6dafe4815147a980c9ac1 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 16:26:03 +0100 Subject: [PATCH 11/33] enhance documentation for PerMessageProfileRoomAssociationWrapper; clarify purpose of associations field --- src/app/hooks/usePerMessageProfile.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index bd167ab1a..5a26c7339 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -44,7 +44,14 @@ type PerMessageProfileRoomAssociation = { 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[]; }; From e321d5f9532338552177af2d38a491d92ab2cf9f Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 16:27:06 +0100 Subject: [PATCH 12/33] refactor: use constant for account data prefix in setCurrentlyUsedPerMessageProfileIdForRoom --- src/app/hooks/usePerMessageProfile.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 5a26c7339..7f6457319 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -142,7 +142,7 @@ export function setCurrentlyUsedPerMessageProfileIdForRoom( validUntil?: number, reset?: boolean ) { - const accountData = mx.getAccountData(`fyi.cisnt.permessageprofile.roomassociation` as any); + const accountData = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any); const content = accountData?.getContent(); const associations: PerMessageProfileRoomAssociation[] = Array.isArray(content) ? content : []; @@ -156,5 +156,5 @@ export function setCurrentlyUsedPerMessageProfileIdForRoom( const wrapper: PerMessageProfileRoomAssociationWrapper = { associations, }; - return mx.setAccountData(`fyi.cisnt.permessageprofile.roomassociation` as any, wrapper as any); + return mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any, wrapper as any); } From 919daf0f5dc949f016eb3e9dd6e57eca81d36d63 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 16:29:55 +0100 Subject: [PATCH 13/33] documentation of code relating to displaying pronouns in the room timeline --- src/app/features/room/message/Message.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 9dd200ed5..b3b299265 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -280,6 +280,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 +299,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, From 7e7adbb7d23996e359bb7a6bfe43b09d130ab919 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 17:25:47 +0100 Subject: [PATCH 14/33] implement per-message profile management and pronouns support --- src/app/features/room/RoomInput.tsx | 25 +--- src/app/features/room/message/Message.tsx | 29 ++++- .../Profiles/PerMessageProfileEditor.tsx | 27 +++- src/app/hooks/useCommands.ts | 5 + src/app/hooks/usePerMessageProfile.ts | 120 ++++++++++++++++++ 5 files changed, 172 insertions(+), 34 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 5d4d11f4b..64f41acf7 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -152,8 +152,8 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; import { - getCurrentlyUsedPerMessageProfileForRoom, - PerMessageProfile, + convertPerMessageProfileToBeeperFormat, + perMessageProfileAtomFamily, } from '$hooks/usePerMessageProfile'; import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; import { SchedulePickerDialog } from './schedule-send'; @@ -275,18 +275,7 @@ export const RoomInput = forwardRef( ); // Add state to cache the profile - const [perMessageProfile, setPerMessageProfile] = useState(null); - - // Fetch and cache the profile when the roomId changes - useEffect(() => { - const fetchPmP = async () => { - const profile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - console.debug('Fetched per-message profile:', { profile, roomId }); - setPerMessageProfile(profile ?? null); // Convert undefined to null - }; - - fetchPmP(); - }, [mx, roomId]); + const perMessageProfile = useAtomValue(perMessageProfileAtomFamily(roomId)); const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey)); @@ -655,12 +644,8 @@ export const RoomInput = forwardRef( perMessageProfile.name, perMessageProfile.avatarUrl ); - content['com.beeper.per_message_profile'] = { - id: perMessageProfile.id, - displayname: perMessageProfile.name, - avatar_url: perMessageProfile.avatarUrl, - 'io.fsky.nyx.pronouns': perMessageProfile.pronouns, - }; + content['com.beeper.per_message_profile'] = + convertPerMessageProfileToBeeperFormat(perMessageProfile); } if (replyDraft && !silentReply) { diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index b3b299265..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'; @@ -376,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. @@ -472,7 +487,7 @@ function MessageInternal( {showPronouns && ( - + )} {tagIconSrc && } diff --git a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx index 616820659..0c237155c 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx @@ -9,7 +9,7 @@ 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 } from '$hooks/usePerMessageProfile'; +import { addOrUpdatePerMessageProfile, deletePerMessageProfile, invalidatePerMessageProfileForProfileId } from '$hooks/usePerMessageProfile'; import { SequenceCardStyle } from '../styles.css'; import { parsePronounsStringToPronounsSetArray, PronounSet } from '$utils/pronouns'; @@ -47,12 +47,12 @@ export function PerMessageProfileEditor({ return [pronounsString] as const; }, [currentPronouns]); const [newPronouns, setNewPronouns] = useState(pronouns); - const newPronounsString = useMemo(() => { + const [newPronounsString, setNewPronounsString] = useState(() => { const pronounsString = Array.isArray(newPronouns) ? newPronouns.map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`).join(', ') : ''; return pronounsString; - }, [newPronouns]); + }); const [newDisplayName, setNewDisplayName] = useState(currentDisplayName); const [imageFile, setImageFile] = useState(); @@ -100,25 +100,37 @@ export function PerMessageProfileEditor({ [newDisplayName, currentDisplayName, newPronouns, currentPronouns, imageFile] ); - // Reset handler for display name + /** + * 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); }); setChangingDisplayName(false); setDisableSetDisplayname(false); + invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); }, [mx, profileId, newDisplayName, avatarMxc, newPronouns]); const handleDelete = useCallback(() => { @@ -129,9 +141,10 @@ export function PerMessageProfileEditor({ }); }, [mx, profileId, onDelete]); - const handlePronounsChange = useCallback(() => { - return setNewPronouns(parsePronounsStringToPronounsSetArray(newPronounsString)); - }, [newPronounsString]); + const handlePronounsChange = useCallback((e: React.ChangeEvent) => { + setNewPronounsString(e.target.value); + return setNewPronouns(parsePronounsStringToPronounsSetArray(e.target.value)); + }, []); return ( { room, mx.getSafeUserId() ); + invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); }) .catch(() => { sendFeedback( @@ -549,6 +552,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { room, mx.getSafeUserId() ); + invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); }) .catch(() => { sendFeedback( @@ -591,6 +595,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { room, mx.getSafeUserId() ); + invalidatePerMessageProfile(room.roomId, () => {}); }) .catch(() => { sendFeedback( diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 7f6457319..1ded9ebe8 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -1,5 +1,8 @@ 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'; @@ -21,9 +24,74 @@ export type PerMessageProfile = { * 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. @@ -72,6 +140,7 @@ export async function getAllPerMessageProfiles(mx: MatrixClient): 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 @@ -158,3 +251,30 @@ export function setCurrentlyUsedPerMessageProfileIdForRoom( }; 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) => { + 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); + }); + }); +} From 80c8597295f1c54efbce45e437d140b6106df50a Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 18:14:17 +0100 Subject: [PATCH 15/33] refactor: replace perMessageProfileAtomFamily with getCurrentlyUsedPerMessageProfileForRoom in RoomInput bc somehow caching didn't really work in any reliable manner --- src/app/features/room/RoomInput.tsx | 8 +++----- src/app/hooks/usePerMessageProfile.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 64f41acf7..c770b9f6d 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -153,7 +153,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; import { convertPerMessageProfileToBeeperFormat, - perMessageProfileAtomFamily, + getCurrentlyUsedPerMessageProfileForRoom, } from '$hooks/usePerMessageProfile'; import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; import { SchedulePickerDialog } from './schedule-send'; @@ -274,9 +274,6 @@ export const RoomInput = forwardRef( room ); - // Add state to cache the profile - const perMessageProfile = useAtomValue(perMessageProfileAtomFamily(roomId)); - const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey)); const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( @@ -637,6 +634,8 @@ export const RoomInput = forwardRef( body, }; + const perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + if (perMessageProfile) { console.warn( 'Using per-message profile:', @@ -742,7 +741,6 @@ export const RoomInput = forwardRef( canSendReaction, mx, roomId, - perMessageProfile, replyDraft, silentReply, scheduledTime, diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 1ded9ebe8..31a9d7b4a 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -267,11 +267,18 @@ export const perMessageProfileAtomFamily = atomFamily((roomId: string) => /** * TO-DO */ -export const invalidatePerMessageProfile = (roomId: string, set: any) => { +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) { +export function invalidatePerMessageProfileForProfileId( + mx: MatrixClient, + profileId: string, + set: any +) { getListOfRoomsUsingProfile(mx, profileId).then((roomIds) => { roomIds.forEach((roomId) => { set(refreshProfileAtomFamily(roomId), (v: number) => v + 1); From 52c42ccbdd6b116d51f347678fe9725c1b7ca753 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 18:17:45 +0100 Subject: [PATCH 16/33] added changeset --- .changeset/add_pmp_sending.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add_pmp_sending.md 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` From 3d58df0468d530edbe26338673e92f8c310723a0 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 18:25:31 +0100 Subject: [PATCH 17/33] remove console warnings for per-message profile handling --- src/app/features/room/RoomInput.tsx | 6 --- .../Profiles/PerMessageProfileEditor.tsx | 39 +++++++++---------- src/app/hooks/usePerMessageProfile.ts | 8 ---- 3 files changed, 18 insertions(+), 35 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c770b9f6d..52e39e29d 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -637,12 +637,6 @@ export const RoomInput = forwardRef( const perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); if (perMessageProfile) { - console.warn( - 'Using per-message profile:', - perMessageProfile.id, - perMessageProfile.name, - perMessageProfile.avatarUrl - ); content['com.beeper.per_message_profile'] = convertPerMessageProfileToBeeperFormat(perMessageProfile); } diff --git a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx index 0c237155c..49e2baeca 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx @@ -9,9 +9,13 @@ 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 } from '$hooks/usePerMessageProfile'; -import { SequenceCardStyle } from '../styles.css'; +import { + addOrUpdatePerMessageProfile, + deletePerMessageProfile, + invalidatePerMessageProfileForProfileId, +} 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. @@ -40,12 +44,6 @@ export function PerMessageProfileEditor({ // Pronouns const [currentPronouns, setCurrentPronouns] = useState(pronouns); - const currentPronounsString = useMemo(() => { - const pronounsString = Array.isArray(currentPronouns) - ? currentPronouns.map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`).join(', ') - : ''; - return [pronounsString] as const; - }, [currentPronouns]); const [newPronouns, setNewPronouns] = useState(pronouns); const [newPronounsString, setNewPronounsString] = useState(() => { const pronounsString = Array.isArray(newPronouns) @@ -267,12 +265,9 @@ export function PerMessageProfileEditor({ style={{ flex: 1, minWidth: 0, height: '100%' }} aria-label="Display name input" > - + + Display Name: + - + Pronouns: + assoc.roomId === roomId)?.profileId; const pmp = profileId ? await getPerMessageProfileById(mx, profileId) : undefined; - console.warn('getCurrentlyUsedPerMessageProfileIdForRoom', { - accountData, - content, - roomId, - profileId, - pmp, - }); return profileId ? pmp : undefined; } From 3c3fa36e6dace12505d4d16a94089c138b52adc2 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 19:08:39 +0100 Subject: [PATCH 18/33] add rename functionality for per-message profiles --- .../Profiles/PerMessageProfileEditor.tsx | 457 ++++++++++-------- src/app/hooks/usePerMessageProfile.ts | 10 + 2 files changed, 259 insertions(+), 208 deletions(-) diff --git a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx index 49e2baeca..63bccb182 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx @@ -13,6 +13,7 @@ import { addOrUpdatePerMessageProfile, deletePerMessageProfile, invalidatePerMessageProfileForProfileId, + renamePerMessageProfile, } from '$hooks/usePerMessageProfile'; import { parsePronounsStringToPronounsSetArray, PronounSet } from '$utils/pronouns'; import { SequenceCardStyle } from '../styles.css'; @@ -41,6 +42,8 @@ export function PerMessageProfileEditor({ }: Readonly) { const useAuthentication = useMediaAuthentication(); const [currentDisplayName, setCurrentDisplayName] = useState(displayName ?? ''); + const [currentId, setCurrentId] = useState(profileId); + const [newId, setNewId] = useState(profileId); // Pronouns const [currentPronouns, setCurrentPronouns] = useState(pronouns); @@ -90,12 +93,15 @@ export function PerMessageProfileEditor({ // 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 ?? '') || newPronouns !== (currentPronouns ?? '') || + hasIdChange || !!imageFile, - [newDisplayName, currentDisplayName, newPronouns, currentPronouns, imageFile] + [newDisplayName, currentDisplayName, newPronouns, currentPronouns, hasIdChange, imageFile] ); /** @@ -126,10 +132,15 @@ export function PerMessageProfileEditor({ 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]); + }, [mx, profileId, newDisplayName, avatarMxc, newPronouns, hasIdChange, newId]); const handleDelete = useCallback(() => { deletePerMessageProfile(mx, profileId).then(() => { @@ -139,6 +150,10 @@ export function PerMessageProfileEditor({ }); }, [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)); @@ -146,7 +161,7 @@ export function PerMessageProfileEditor({ return ( - - Profile ID: {profileId} - - - - ( - - p - - )} - alt={`Avatar for profile ${profileId}`} - /> - - - {uploadAtom && ( - - - - )} - + {/* Profile ID heading and input */} - - Display Name: - - - - - ) - } - /> - - Pronouns: + + Profile ID: - - - ) - } + placeholder="Profile ID" + aria-label="profile id" + title="profile id" /> - {/* Rechte Spalte: Save Button */} - - - + {uploadAtom && ( + + + + )} + + - Delete - + + Display Name: + + + + + ) + } + /> + + Pronouns: + + + + + ) + } + /> + + {/* Rechte Spalte: Save Button */} + + + + diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 7e509d5ef..02b9e8e07 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -156,6 +156,16 @@ export function deletePerMessageProfile(mx: MatrixClient, id: string) { return mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.${id}` as any, {}); } +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 From 793ce6deebbb8d5aa083160c3a0e46793e4c414a Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 19:23:11 +0100 Subject: [PATCH 19/33] actually delete the entry from the index when --- src/app/hooks/usePerMessageProfile.ts | 29 +++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 02b9e8e07..6faf70539 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -152,8 +152,33 @@ export function addOrUpdatePerMessageProfile(mx: MatrixClient, profile: PerMessa ]); } -export function deletePerMessageProfile(mx: MatrixClient, id: string) { - return mx.setAccountData(`${ACCOUNT_DATA_PREFIX}.${id}` 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) { From 3b4a12f689eabd9a9aad13ae88399a3e8cc4d0a0 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 19:33:22 +0100 Subject: [PATCH 20/33] fix: update import statements for PronounEditor and PronounSet in Cosmetics.tsx --- src/app/features/common-settings/cosmetics/Cosmetics.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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'); From 8360ee1f50748293a42236b955aef4320c4a54cd Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 19:40:52 +0100 Subject: [PATCH 21/33] fix: add missing key prop to PerMessageProfileEditor component --- src/app/features/settings/Profiles/PerMessageProfileOverview.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx b/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx index 5f4a20615..f2e74e657 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx @@ -51,6 +51,7 @@ export function PerMessageProfileOverview() { {profiles.map((profile) => ( Date: Tue, 17 Mar 2026 19:59:12 +0100 Subject: [PATCH 22/33] fixed pronouns error --- .../Profiles/PerMessageProfileEditor.tsx | 20 +++++++++++++++++-- .../Profiles/PerMessageProfileOverview.tsx | 1 + 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx index 63bccb182..0e6094923 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx @@ -45,9 +45,18 @@ export function PerMessageProfileEditor({ 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(', ') @@ -98,10 +107,17 @@ export function PerMessageProfileEditor({ const hasChanges = useMemo( () => newDisplayName !== (currentDisplayName ?? '') || - newPronouns !== (currentPronouns ?? '') || + newPronounsString !== currentPronounsString || hasIdChange || !!imageFile, - [newDisplayName, currentDisplayName, newPronouns, currentPronouns, hasIdChange, imageFile] + [ + newDisplayName, + currentDisplayName, + newPronounsString, + currentPronounsString, + hasIdChange, + imageFile, + ] ); /** diff --git a/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx b/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx index f2e74e657..6ed0dcef0 100644 --- a/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx +++ b/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx @@ -55,6 +55,7 @@ export function PerMessageProfileOverview() { profileId={profile.id} avatarMxcUrl={profile.avatarUrl} displayName={profile.name} + pronouns={profile.pronouns} onDelete={handleDelete} /> ))} From d91adfb032bc287c0a0d17fa1070a2d0e582d65a Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 20:13:17 +0100 Subject: [PATCH 23/33] refactor: animalCosmetics --- .../settings/account/AnimalCosmetics.tsx | 75 +++++++++++++++++++ src/app/features/settings/account/Profile.tsx | 68 +---------------- 2 files changed, 76 insertions(+), 67 deletions(-) create mode 100644 src/app/features/settings/account/AnimalCosmetics.tsx 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()!; From 67056a264d95aa33d669b5fc777927cb0b9eda70 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 17 Mar 2026 20:13:59 +0100 Subject: [PATCH 24/33] refactoring pmp --- .../{Profiles => Persona}/PerMessageProfileEditor.tsx | 0 .../{Profiles => Persona}/PerMessageProfileOverview.tsx | 0 .../features/settings/{Profiles => Persona}/ProfilesPage.tsx | 2 +- src/app/features/settings/Settings.tsx | 4 ++-- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/app/features/settings/{Profiles => Persona}/PerMessageProfileEditor.tsx (100%) rename src/app/features/settings/{Profiles => Persona}/PerMessageProfileOverview.tsx (100%) rename src/app/features/settings/{Profiles => Persona}/ProfilesPage.tsx (97%) diff --git a/src/app/features/settings/Profiles/PerMessageProfileEditor.tsx b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx similarity index 100% rename from src/app/features/settings/Profiles/PerMessageProfileEditor.tsx rename to src/app/features/settings/Persona/PerMessageProfileEditor.tsx diff --git a/src/app/features/settings/Profiles/PerMessageProfileOverview.tsx b/src/app/features/settings/Persona/PerMessageProfileOverview.tsx similarity index 100% rename from src/app/features/settings/Profiles/PerMessageProfileOverview.tsx rename to src/app/features/settings/Persona/PerMessageProfileOverview.tsx diff --git a/src/app/features/settings/Profiles/ProfilesPage.tsx b/src/app/features/settings/Persona/ProfilesPage.tsx similarity index 97% rename from src/app/features/settings/Profiles/ProfilesPage.tsx rename to src/app/features/settings/Persona/ProfilesPage.tsx index 3b26cd8dd..2fe56f51c 100644 --- a/src/app/features/settings/Profiles/ProfilesPage.tsx +++ b/src/app/features/settings/Persona/ProfilesPage.tsx @@ -13,7 +13,7 @@ export function PerMessageProfilePage({ requestClose }: PerMessageProfilePagePro - Profiles (Per-Message) + Persona diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 55fde6137..4724b61cf 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -36,7 +36,7 @@ import { General } from './general'; import { Cosmetics } from './cosmetics/Cosmetics'; import { Experimental } from './experimental/Experimental'; import { KeyboardShortcuts } from './keyboard-shortcuts'; -import { PerMessageProfilePage } from './Profiles/ProfilesPage'; +import { PerMessageProfilePage } from './Persona/ProfilesPage'; export enum SettingsPages { GeneralPage, @@ -74,7 +74,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] => }, { page: SettingsPages.PerMessageProfilesPage, - name: 'Profiles', + name: 'Persona', icon: Icons.User, }, { From cd4079be998b2da69c00e5ac1732a57749e75529 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 18 Mar 2026 13:02:51 +0100 Subject: [PATCH 25/33] remove unused profile invalidation functions and fix profile association handling --- .../Persona/PerMessageProfileEditor.tsx | 2 - src/app/hooks/useCommands.ts | 13 +- src/app/hooks/usePerMessageProfile.ts | 240 +++++++++--------- 3 files changed, 121 insertions(+), 134 deletions(-) diff --git a/src/app/features/settings/Persona/PerMessageProfileEditor.tsx b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx index 0e6094923..bbfcbcde5 100644 --- a/src/app/features/settings/Persona/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx @@ -12,7 +12,6 @@ import { CompactUploadCardRenderer } from '$components/upload-card'; import { addOrUpdatePerMessageProfile, deletePerMessageProfile, - invalidatePerMessageProfileForProfileId, renamePerMessageProfile, } from '$hooks/usePerMessageProfile'; import { parsePronounsStringToPronounsSetArray, PronounSet } from '$utils/pronouns'; @@ -155,7 +154,6 @@ export function PerMessageProfileEditor({ } setChangingDisplayName(false); setDisableSetDisplayname(false); - invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); }, [mx, profileId, newDisplayName, avatarMxc, newPronouns, hasIdChange, newId]); const handleDelete = useCallback(() => { diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 216011023..2a0d45666 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -38,8 +38,6 @@ import { useUserProfile } from './useUserProfile'; import { addOrUpdatePerMessageProfile, deletePerMessageProfile, - invalidatePerMessageProfile, - invalidatePerMessageProfileForProfileId, PerMessageProfile, setCurrentlyUsedPerMessageProfileIdForRoom, } from './usePerMessageProfile'; @@ -524,7 +522,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { room, mx.getSafeUserId() ); - invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); }) .catch(() => { sendFeedback( @@ -552,7 +549,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { room, mx.getSafeUserId() ); - invalidatePerMessageProfileForProfileId(mx, profileId, () => {}); }) .catch(() => { sendFeedback( @@ -577,9 +573,9 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { .then(() => { sendFeedback('Per message profile reset for this room.', room, mx.getSafeUserId()); }) - .catch(() => { + .catch((e) => { sendFeedback( - 'Failed to reset per message profile for this room.', + `Failed to reset per message profile for this room. Failed with: "${e.message}"`, room, mx.getSafeUserId() ); @@ -595,11 +591,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { room, mx.getSafeUserId() ); - invalidatePerMessageProfile(room.roomId, () => {}); }) - .catch(() => { + .catch((e) => { sendFeedback( - 'Failed to set per message profile for this room.', + `Failed to set per message profile for this room. Failed with: "${e.message}"`, room, mx.getSafeUserId() ); diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 6faf70539..fb0ef3efc 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -1,8 +1,5 @@ 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'; @@ -99,17 +96,12 @@ type PerMessageProfileIndex = { profileIds: string[]; }; +/** + * how we will store room associations in the account data :3 + */ 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. + validUntil?: number; }; /** @@ -118,21 +110,42 @@ type PerMessageProfileRoomAssociation = { */ 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. + * 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: PerMessageProfileRoomAssociation[]; + 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 = await mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.${id}` as any); + 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 = await mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.index` as any); + 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); @@ -152,6 +165,14 @@ export function addOrUpdatePerMessageProfile(mx: MatrixClient, profile: PerMessa ]); } +/** + * 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 || []; @@ -162,25 +183,95 @@ async function dropIdFromIndex(mx: MatrixClient, id: string) { ); } -async function dropPerMessageProfileRoomAssociations(mx: MatrixClient, id: string) { +async function getRoomsUsingProfile(mx: MatrixClient, profileId: string): Promise { const accountData = mx.getAccountData(`${ACCOUNT_DATA_PREFIX}.roomassociation` as any); - const content = accountData?.getContent(); + 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; +} - const associations: PerMessageProfileRoomAssociation[] = Array.isArray(content) ? content : []; +/** + * 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: associations.filter((assoc) => assoc.profileId !== id), - } 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) { @@ -195,24 +286,7 @@ 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; + return getRoomsUsingProfile(mx, profileId); } /** @@ -226,89 +300,9 @@ export async function getCurrentlyUsedPerMessageProfileForRoom( 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 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; } - -/** - * 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); - }); - }); -} From 8d598fdf3fdce1521d03036f7767bb3c2fe4d660 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 18 Mar 2026 14:48:20 +0100 Subject: [PATCH 26/33] fix a few pmp editing bugs --- .../Persona/PerMessageProfileEditor.tsx | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/src/app/features/settings/Persona/PerMessageProfileEditor.tsx b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx index bbfcbcde5..00a0ff9fe 100644 --- a/src/app/features/settings/Persona/PerMessageProfileEditor.tsx +++ b/src/app/features/settings/Persona/PerMessageProfileEditor.tsx @@ -65,6 +65,7 @@ export function PerMessageProfileEditor({ 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(() => { @@ -81,12 +82,14 @@ export function PerMessageProfileEditor({ const pickFile = useFilePicker(setImageFile, false); const handleRemoveUpload = useCallback(() => { setImageFile(undefined); + setImageHasChanges(true); }, []); const handleUploaded = useCallback( (upload: { status: string; mxc: string }) => { - if (upload && upload.status === 'success') { + if (upload?.status === 'success') { setAvatarMxc(upload.mxc); if (onChange) onChange({ id: profileId, name: newDisplayName, avatarUrl: upload.mxc }); + setImageHasChanges(true); } setImageFile(undefined); }, @@ -108,31 +111,31 @@ export function PerMessageProfileEditor({ newDisplayName !== (currentDisplayName ?? '') || newPronounsString !== currentPronounsString || hasIdChange || - !!imageFile, + imageHasChanges, [ newDisplayName, currentDisplayName, newPronounsString, currentPronounsString, hasIdChange, - imageFile, + imageHasChanges, ] ); /** - * Reset handler to reset the display name and pronouns to their current values, and clear the image file if there is one. + * handler for resetting the display name */ - const handleReset = useCallback(() => { - setNewDisplayName(currentDisplayName); + const handleDisplayNameReset = useCallback(() => { + setNewDisplayName(currentDisplayName ?? ''); + }, [currentDisplayName]); + + /** + * handler for resetting the pronouns + */ + const handlePronounsReset = useCallback(() => { setNewPronouns(currentPronouns); - setNewPronounsString( - Array.isArray(currentPronouns) - ? currentPronouns.map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`).join(', ') - : '' - ); - setChangingDisplayName(false); - setDisableSetDisplayname(false); - }, [currentDisplayName, currentPronouns]); + setNewPronounsString(currentPronounsString); + }, [currentPronouns, currentPronounsString]); /** * persisting the data :3 @@ -146,14 +149,15 @@ export function PerMessageProfileEditor({ }).then(() => { setCurrentDisplayName(newDisplayName); setCurrentPronouns(newPronouns); + setImageHasChanges(false); + setChangingDisplayName(false); + setDisableSetDisplayname(false); + if (hasIdChange) { + renamePerMessageProfile(mx, profileId, newId).then(() => { + setCurrentId(newId); + }); + } }); - if (hasIdChange) { - renamePerMessageProfile(mx, profileId, newId).then(() => { - setCurrentId(newId); - }); - } - setChangingDisplayName(false); - setDisableSetDisplayname(false); }, [mx, profileId, newDisplayName, avatarMxc, newPronouns, hasIdChange, newId]); const handleDelete = useCallback(() => { @@ -344,11 +348,11 @@ export function PerMessageProfileEditor({ aria-label={`Display name for ${profileId}`} title={`Display name for ${profileId}`} after={ - hasChanges && + newDisplayName !== (currentDisplayName ?? '') && !changingDisplayName && ( Date: Wed, 18 Mar 2026 14:59:04 +0100 Subject: [PATCH 27/33] merging from dev --- src/app/features/room/message/Message.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 87bd0e521..97597ad4c 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -457,7 +457,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); @@ -471,7 +471,7 @@ function MessageInternal( } return existing; - }, [profile.pronouns, inlinePronoun]); + }, [pronouns, inlinePronoun]); useEffect(() => { if (!mobileOptionsOpen) return undefined; @@ -508,7 +508,7 @@ function MessageInternal( {showPronouns && ( - + )} {tagIconSrc && } From 93a93a686a1cf0a702b52c2ded9fa9805f2d68d4 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 18 Mar 2026 15:03:44 +0100 Subject: [PATCH 28/33] doc: a bit of code doc --- src/app/features/room/RoomInput.tsx | 5 +++++ src/app/features/room/message/Message.tsx | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index dbd517252..c650d55e4 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -688,6 +688,11 @@ export const RoomInput = forwardRef( body, }; + /** + * 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) { diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 97597ad4c..0385c7d0d 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -380,6 +380,11 @@ function MessageInternal( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + /** + * 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 From cee2cb6118b3505149f97deaee805df25affe5ed Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 18 Mar 2026 21:56:31 +0100 Subject: [PATCH 29/33] added fallback body to be spec compliant --- .../components/message/MsgTypeRenderers.tsx | 22 ++++++++++ src/app/features/room/RoomInput.tsx | 41 +++++++++++++++---- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index d1e2601ad..93c67bbad 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -109,6 +109,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 +135,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']) { + // unwrap per-message profile fallback if present + return ( + + {renderBody({ + body: trimmedBody, + customBody: unwrappedPerMessageProfileMessage, + })} + {edited && } + + ); + } + if (isForwarded && unwrappedForwardedContent) { return ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c650d55e4..44094a53b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -159,6 +159,7 @@ import { } 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 { @@ -688,6 +689,17 @@ export const RoomInput = forwardRef( body, }; + if (replyDraft && !silentReply) { + mentionData.users.add(replyDraft.userId); + } + + content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); + + if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { + 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, @@ -698,18 +710,31 @@ export const RoomInput = forwardRef( if (perMessageProfile) { content['com.beeper.per_message_profile'] = convertPerMessageProfileToBeeperFormat(perMessageProfile); - } - if (replyDraft && !silentReply) { - mentionData.users.add(replyDraft.userId); - } + // if a per-message profile is used, it must per spec include a fallback + const prefix = `${perMessageProfile.name}: `; - content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); + if (!content.body.startsWith(prefix)) { + // to prevent double-prefixing when the fallback is already present + content.body = prefix + content.body; + } - if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { - content.format = 'org.matrix.custom.html'; - content.formatted_body = formattedBody; + /** + * 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); } From aaa802606d43514042cb44341f0b20c2adf595ac Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 18 Mar 2026 22:33:41 +0100 Subject: [PATCH 30/33] added missing questionmark lol --- src/app/components/message/MsgTypeRenderers.tsx | 3 ++- src/app/hooks/usePerMessageProfile.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 93c67bbad..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 { @@ -135,7 +136,7 @@ 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']) { + if ((content['com.beeper.per_message_profile'] as PerMessageProfileBeeperFormat)?.has_fallback) { // unwrap per-message profile fallback if present return ( diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index fb0ef3efc..8ef2f8d44 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -50,6 +50,7 @@ export type PerMessageProfileBeeperFormat = { * using the unstable prefix for pronouns, under which it is also stored in profiles */ 'io.fsky.nyx.pronouns'?: PronounSet[]; + has_fallback: boolean; }; /** @@ -67,6 +68,7 @@ export function convertPerMessageProfileToBeeperFormat( displayname: profile.name, avatar_url: profile.avatarUrl, 'io.fsky.nyx.pronouns': profile.pronouns, + has_fallback: true, }; } From b7a1cb8d7eca121dff7a99bc1fbed98bcc63171d Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 19 Mar 2026 20:27:41 +0100 Subject: [PATCH 31/33] added visual marker to mark it as a pmp --- src/app/features/room/message/Message.tsx | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 0385c7d0d..88a4349ff 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -401,6 +401,10 @@ function MessageInternal( 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); @@ -515,6 +519,26 @@ function MessageInternal( {showPronouns && ( )} + {showPmPInfo && ( + + + + via + + + {senderDisplayName} + + + + )} {tagIconSrc && } From 269e7a16bcf310989fa4ee8a665adf088258441e Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 19 Mar 2026 20:34:49 +0100 Subject: [PATCH 32/33] fixed placement of space in fallback --- src/app/features/room/RoomInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 44094a53b..19715dace 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -724,7 +724,7 @@ export const RoomInput = forwardRef( */ const escapedName = sanitizeCustomHtml(perMessageProfile.name); - const htmlPrefix = `${escapedName}: `; + const htmlPrefix = `${escapedName}: `; if (content.formatted_body && !content.formatted_body.startsWith(htmlPrefix)) { content.formatted_body = htmlPrefix + content.formatted_body; From 1a7e71c7fcab4ca65454df8bbd672fafcd0482da Mon Sep 17 00:00:00 2001 From: Rye Date: Thu, 19 Mar 2026 20:40:22 +0100 Subject: [PATCH 33/33] removed unnecessary space oops --- src/app/features/room/RoomInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 19715dace..3e264c53b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -731,7 +731,7 @@ export const RoomInput = forwardRef( } else { // we don't have a formatted body, but we need one content.format = 'org.matrix.custom.html'; - content.formatted_body = `${htmlPrefix} ${plainText}`; + content.formatted_body = `${htmlPrefix}${plainText}`; } }