From ed689792ee6d71a47bdd787ab5fb09c523dfe88c Mon Sep 17 00:00:00 2001 From: Rye Date: Mon, 16 Mar 2026 17:44:30 +0100 Subject: [PATCH 01/24] 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 00000000..394a42c3 --- /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/24] 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 0c2008c0..a9a4cfd7 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 394a42c3..67818674 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/24] 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 1b0e6f5f..2773dc77 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 67818674..8107940a 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/24] 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 a9a4cfd7..e4ccbfea 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/24] 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 b03c1778..6b14c44b 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/24] 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 a137eee3..39cbe6d6 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 6b14c44b..98314ff2 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/24] 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 00000000..2672f527 --- /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 00000000..d90e2073 --- /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 00000000..55ff3c28 --- /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 34f350b3..139af2b9 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/24] 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 2672f527..00000000 --- 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 00000000..c91328a5 --- /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 d90e2073..879a6dbc 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 139af2b9..55fde613 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/24] 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 c91328a5..08999785 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 879a6dbc..5f4a2061 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 56eff745..3b26cd8d 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/24] 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 39cbe6d6..5d4d11f4 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 08999785..61682065 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 c653d9fd..4af0d386 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/24] 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 bd167ab1..5a26c733 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/24] 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 5a26c733..7f645731 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/24] 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 9dd200ed..b3b29926 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/24] 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 5d4d11f4..64f41acf 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 b3b29926..b0956271 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 61682065..0c237155 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 7f645731..1ded9ebe 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/24] 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 64f41acf..c770b9f6 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 1ded9ebe..31a9d7b4 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/24] 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 00000000..5b5c2419 --- /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/24] 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 c770b9f6..52e39e29 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 0c237155..49e2baec 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/24] 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 49e2baec..63bccb18 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 7e509d5e..02b9e8e0 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/24] 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 02b9e8e0..6faf7053 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/24] 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 dbfbd139..be793be7 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/24] 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 5f4a2061..f2e74e65 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/24] 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 63bccb18..0e609492 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 f2e74e65..6ed0dcef 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/24] 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 00000000..ada07ef9 --- /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 68daf9bf..45c66eff 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/24] 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 3b26cd8d..2fe56f51 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 55fde613..4724b61c 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, }, {