Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ed68979
implement per message profile management functions
dozro Mar 16, 2026
2a83578
add per message profile management commands and functionality
dozro Mar 16, 2026
1680ab9
integrate per-message profile fetching in RoomInput component
dozro Mar 16, 2026
4250f24
enhance per message profile command to support key=value parsing for …
dozro Mar 16, 2026
2e752da
Merge branch 'dev' into feat/allow-setting-pmp
dozro Mar 16, 2026
b17293e
enhance per message profile commands with feedback on success and fai…
dozro Mar 16, 2026
9348a8a
enhance RoomInput to cache per-message profile and improve fetching l…
dozro Mar 16, 2026
6a0c2e3
add PerMessageProfile editor and overview components; integrate into …
dozro Mar 16, 2026
cd1e87a
restructure per-message profile components and update settings integr…
dozro Mar 17, 2026
d279c40
add delete functionality to PerMessageProfileEditor and integrate int…
dozro Mar 17, 2026
26bc374
add pronouns support to PerMessageProfile; update editor and hooks fo…
dozro Mar 17, 2026
0ef60ca
enhance documentation for PerMessageProfileRoomAssociationWrapper; cl…
dozro Mar 17, 2026
e321d5f
refactor: use constant for account data prefix in setCurrentlyUsedPer…
dozro Mar 17, 2026
919daf0
documentation of code relating to displaying pronouns in the room tim…
dozro Mar 17, 2026
7e7adbb
implement per-message profile management and pronouns support
dozro Mar 17, 2026
80c8597
refactor: replace perMessageProfileAtomFamily with getCurrentlyUsedPe…
dozro Mar 17, 2026
52c42cc
added changeset
dozro Mar 17, 2026
3d58df0
remove console warnings for per-message profile handling
dozro Mar 17, 2026
3c3fa36
add rename functionality for per-message profiles
dozro Mar 17, 2026
793ce6d
actually delete the entry from the index when
dozro Mar 17, 2026
3b4a12f
fix: update import statements for PronounEditor and PronounSet in Cos…
dozro Mar 17, 2026
8360ee1
fix: add missing key prop to PerMessageProfileEditor component
dozro Mar 17, 2026
b56f2eb
fixed pronouns error
dozro Mar 17, 2026
d91adfb
refactor: animalCosmetics
dozro Mar 17, 2026
67056a2
refactoring pmp
dozro Mar 17, 2026
e312028
Merge branch 'dev' into feat/allow-setting-pmp
dozro Mar 17, 2026
cd4079b
remove unused profile invalidation functions and fix profile associat…
dozro Mar 18, 2026
8d598fd
fix a few pmp editing bugs
dozro Mar 18, 2026
c836e90
Merge remote-tracking branch 'fork-new/dev' into feat/allow-setting-pmp
dozro Mar 18, 2026
2ad4fa0
merging from dev
dozro Mar 18, 2026
93a93a6
doc: a bit of code doc
dozro Mar 18, 2026
cee2cb6
added fallback body to be spec compliant
dozro Mar 18, 2026
aaa8026
added missing questionmark lol
dozro Mar 18, 2026
b7a1cb8
added visual marker to mark it as a pmp
dozro Mar 19, 2026
269e7a1
fixed placement of space in fallback
dozro Mar 19, 2026
1a7e71c
removed unnecessary space oops
dozro Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add_pmp_sending.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

added the posibility to send using per message profiles with `/usepmp`
23 changes: 23 additions & 0 deletions src/app/components/message/MsgTypeRenderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '$utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '$utils/common';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { PerMessageProfileBeeperFormat } from '$hooks/usePerMessageProfile';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
import {
Expand Down Expand Up @@ -109,6 +110,15 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
const forwardMeta = content['moe.sable.message.forward'];
return typeof forwardMeta === 'object';
}, [content]);

/**
* For the unwrapping of per-message profile fallbacks, we look for <strong> tags with the data-mx-profile-fallback attribute
*/
const unwrappedPerMessageProfileMessage = useMemo(
() => customBody?.replace(/<strong[^>]*data-mx-profile-fallback[^>]*>(.*?):\s*<\/strong>/i, ''),
[customBody]
);

const isJumbo = useMemo(() => {
if (!trimmedBody || trimmedBody.length >= 500) return false;
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return false;
Expand All @@ -126,6 +136,19 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;

if ((content['com.beeper.per_message_profile'] as PerMessageProfileBeeperFormat)?.has_fallback) {
// unwrap per-message profile fallback if present
return (
<MessageTextBody preWrap={typeof customBody !== 'string'} style={style}>
{renderBody({
body: trimmedBody,
customBody: unwrappedPerMessageProfileMessage,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}

if (isForwarded && unwrappedForwardedContent) {
return (
<MessageTextBody preWrap={typeof unwrappedForwardedContent !== 'string'} style={style}>
Expand Down
3 changes: 2 additions & 1 deletion src/app/features/common-settings/cosmetics/Cosmetics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
45 changes: 43 additions & 2 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,13 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice';
import {
convertPerMessageProfileToBeeperFormat,
getCurrentlyUsedPerMessageProfileForRoom,
} from '$hooks/usePerMessageProfile';
import { Microphone, Stop } from '@phosphor-icons/react';
import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec';
import { sanitizeCustomHtml } from '$utils/sanitize';
import { SchedulePickerDialog } from './schedule-send';
import * as css from './schedule-send/SchedulePickerDialog.css';
import {
Expand Down Expand Up @@ -694,6 +699,42 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content.format = 'org.matrix.custom.html';
content.formatted_body = formattedBody;
}

/**
* the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending.
* This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message,
* and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed.
*/
const perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId);

if (perMessageProfile) {
content['com.beeper.per_message_profile'] =
convertPerMessageProfileToBeeperFormat(perMessageProfile);

// if a per-message profile is used, it must per spec include a fallback
const prefix = `${perMessageProfile.name}: `;

if (!content.body.startsWith(prefix)) {
// to prevent double-prefixing when the fallback is already present
content.body = prefix + content.body;
}

/**
* html escaped version of the display name
*/
const escapedName = sanitizeCustomHtml(perMessageProfile.name);

const htmlPrefix = `<strong data-mx-profile-fallback>${escapedName}: </strong>`;

if (content.formatted_body && !content.formatted_body.startsWith(htmlPrefix)) {
content.formatted_body = htmlPrefix + content.formatted_body;
} else {
// we don't have a formatted body, but we need one
content.format = 'org.matrix.custom.html';
content.formatted_body = `${htmlPrefix}${plainText}`;
}
}

if (replyDraft) {
content['m.relates_to'] = getReplyContent(replyDraft, room);
}
Expand Down Expand Up @@ -794,20 +835,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
canSendReaction,
mx,
roomId,
threadRootId,
replyDraft,
silentReply,
scheduledTime,
editingScheduledDelayId,
handleQuickReact,
commands,
sendTypingStatus,
room,
queryClient,
threadRootId,
setReplyDraft,
isEncrypted,
setEditingScheduledDelayId,
setScheduledTime,
room,
]);

const handleKeyDown: KeyboardEventHandler = useCallback(
Expand Down
71 changes: 63 additions & 8 deletions src/app/features/room/message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -280,6 +284,10 @@ function useMobileDoubleTap(callback: () => void, delay = 300) {
);
}

/**
* Component to render pronouns in the chat timeline.
* It also filters them.
*/
const Pronouns = as<
'span',
{
Expand All @@ -295,6 +303,13 @@ const Pronouns = as<
.map((lang) => lang.trim().toLowerCase())
.filter(Boolean);

/**
* filter the pronouns based on the user's language settings.
* If filtering is enabled, only show pronouns that match the selected languages.
* If filtering is disabled, show all pronouns but still apply the language filter to determine which pronouns to show if there are multiple sets of pronouns for different languages.
* If there are multiple sets of pronouns and filtering is enabled, only show the ones that match the selected languages.
* If there are no pronouns that match the selected languages, show all pronouns.
*/
const visiblePronouns = filterPronounsByLanguage(
pronouns,
languageFilterEnabled,
Expand Down Expand Up @@ -365,22 +380,42 @@ function MessageInternal(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();

const pmp = useMemo(
/**
* We read the per-message profile from the event content here.
* We have to do this in the message component because the per-message profile can be different for each message, and we need to read it for each message individually.
* We also want to avoid reading and parsing the per-message profile in a parent component like the timeline, because that would be inefficient and would cause unnecessary re-renders of the entire timeline whenever a per-message profile changes.
*/
const pmp: PerMessageProfileBeeperFormat | undefined = useMemo(
() =>
mEvent.event.content?.['com.beeper.per_message_profile'] as
| {
avatar_url: string | undefined;
displayname: string | undefined;
id: string | undefined;
}
| PerMessageProfileBeeperFormat
| undefined,
[mEvent]
);

/**
* We convert the per-message profile from the Beeper format to our internal format here in the message component
*/
const parsedPMPContent = useMemo(() => {
if (!pmp) return undefined;
return convertBeeperFormatToOurPerMessageProfile(pmp);
}, [pmp]);

/**
* boolean to indicate wheather we should indicate to the user that it is a pmp
*/
const showPmPInfo = pmp !== undefined;
// Profiles and Colors
const profile = useUserProfile(senderId, room);
const { color: usernameColor, font: usernameFont } = useSableCosmetics(senderId, room);

/**
* If there is a per-message profile, we want to use the per message pronouns,
* otherwise we fall back to the profile pronouns.
* This allows users to set pronouns on a per-message basis, while still falling back to their profile pronouns if they don't set any for a specific message.
*/
const pronouns = parsedPMPContent?.pronouns ?? profile.pronouns;

const [highlightMentions] = useSetting(settingsAtom, 'highlightMentions');

// Avatars
Expand Down Expand Up @@ -431,7 +466,7 @@ function MessageInternal(
}, [pmp, senderDisplayName, parsePronouns]);

const mergedPronouns = useMemo(() => {
const existing = profile.pronouns ? [...profile.pronouns] : [];
const existing = pronouns ? [...pronouns] : [];

if (inlinePronoun) {
const isDupe = existing.some((p) => p.summary?.toLowerCase() === inlinePronoun);
Expand All @@ -445,7 +480,7 @@ function MessageInternal(
}

return existing;
}, [profile.pronouns, inlinePronoun]);
}, [pronouns, inlinePronoun]);

useEffect(() => {
if (!mobileOptionsOpen) return undefined;
Expand Down Expand Up @@ -484,6 +519,26 @@ function MessageInternal(
{showPronouns && (
<Pronouns pronouns={mergedPronouns} tagColor={usernameColor ?? 'currentColor'} />
)}
{showPmPInfo && (
<Box>
<Text as="span">
<Text
as="span"
style={{ paddingLeft: 0, paddingRight: 5, fontWeight: 100, fontSize: 11 }}
>
via
</Text>
<Text
as="span"
size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'}
style={{ fontSize: 11 }}
truncate
>
<UsernameBold>{senderDisplayName}</UsernameBold>
</Text>
</Text>
</Box>
)}
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Box shrink="No" gap="100">
Expand Down
Loading
Loading