Skip to content

Commit 1e46dd6

Browse files
authored
Merge pull request #309 from dozro/feat/allow-setting-pmp
Implement per-message profile management and sending with a per-message profile
2 parents 4a82c92 + 1a7e71c commit 1e46dd6

15 files changed

Lines changed: 1244 additions & 85 deletions

File tree

.changeset/add_pmp_sending.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
added the posibility to send using per message profiles with `/usepmp`

src/app/components/message/MsgTypeRenderers.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '$utils/mimeTypes';
2121
import { parseGeoUri, scaleYDimension } from '$utils/common';
2222
import { useSetting } from '$state/hooks/settings';
2323
import { settingsAtom } from '$state/settings';
24+
import { PerMessageProfileBeeperFormat } from '$hooks/usePerMessageProfile';
2425
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
2526
import { FileHeader, FileDownloadButton } from './FileHeader';
2627
import {
@@ -109,6 +110,15 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
109110
const forwardMeta = content['moe.sable.message.forward'];
110111
return typeof forwardMeta === 'object';
111112
}, [content]);
113+
114+
/**
115+
* For the unwrapping of per-message profile fallbacks, we look for <strong> tags with the data-mx-profile-fallback attribute
116+
*/
117+
const unwrappedPerMessageProfileMessage = useMemo(
118+
() => customBody?.replace(/<strong[^>]*data-mx-profile-fallback[^>]*>(.*?):\s*<\/strong>/i, ''),
119+
[customBody]
120+
);
121+
112122
const isJumbo = useMemo(() => {
113123
if (!trimmedBody || trimmedBody.length >= 500) return false;
114124
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return false;
@@ -126,6 +136,19 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
126136
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
127137
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
128138

139+
if ((content['com.beeper.per_message_profile'] as PerMessageProfileBeeperFormat)?.has_fallback) {
140+
// unwrap per-message profile fallback if present
141+
return (
142+
<MessageTextBody preWrap={typeof customBody !== 'string'} style={style}>
143+
{renderBody({
144+
body: trimmedBody,
145+
customBody: unwrappedPerMessageProfileMessage,
146+
})}
147+
{edited && <MessageEditedContent />}
148+
</MessageTextBody>
149+
);
150+
}
151+
129152
if (isForwarded && unwrappedForwardedContent) {
130153
return (
131154
<MessageTextBody preWrap={typeof unwrappedForwardedContent !== 'string'} style={style}>

src/app/features/common-settings/cosmetics/Cosmetics.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ import { ImageEditor } from '$components/image-editor';
5656
import { stopPropagation } from '$utils/keyboard';
5757
import { ModalWide } from '$styles/Modal.css';
5858
import { NameColorEditor } from '$features/settings/account/NameColorEditor';
59-
import { PronounEditor, PronounSet } from '$features/settings/account/PronounEditor';
59+
import { PronounEditor } from '$features/settings/account/PronounEditor';
60+
import { PronounSet } from '$utils/pronouns';
6061

6162
const log = createLogger('Cosmetics');
6263

src/app/features/room/RoomInput.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,13 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels';
153153
import { useRoomCreators } from '$hooks/useRoomCreators';
154154
import { useRoomPermissions } from '$hooks/useRoomPermissions';
155155
import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice';
156+
import {
157+
convertPerMessageProfileToBeeperFormat,
158+
getCurrentlyUsedPerMessageProfileForRoom,
159+
} from '$hooks/usePerMessageProfile';
156160
import { Microphone, Stop } from '@phosphor-icons/react';
157161
import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec';
162+
import { sanitizeCustomHtml } from '$utils/sanitize';
158163
import { SchedulePickerDialog } from './schedule-send';
159164
import * as css from './schedule-send/SchedulePickerDialog.css';
160165
import {
@@ -754,6 +759,42 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
754759
content.format = 'org.matrix.custom.html';
755760
content.formatted_body = formattedBody;
756761
}
762+
763+
/**
764+
* the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending.
765+
* This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message,
766+
* and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed.
767+
*/
768+
const perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId);
769+
770+
if (perMessageProfile) {
771+
content['com.beeper.per_message_profile'] =
772+
convertPerMessageProfileToBeeperFormat(perMessageProfile);
773+
774+
// if a per-message profile is used, it must per spec include a fallback
775+
const prefix = `${perMessageProfile.name}: `;
776+
777+
if (!content.body.startsWith(prefix)) {
778+
// to prevent double-prefixing when the fallback is already present
779+
content.body = prefix + content.body;
780+
}
781+
782+
/**
783+
* html escaped version of the display name
784+
*/
785+
const escapedName = sanitizeCustomHtml(perMessageProfile.name);
786+
787+
const htmlPrefix = `<strong data-mx-profile-fallback>${escapedName}: </strong>`;
788+
789+
if (content.formatted_body && !content.formatted_body.startsWith(htmlPrefix)) {
790+
content.formatted_body = htmlPrefix + content.formatted_body;
791+
} else {
792+
// we don't have a formatted body, but we need one
793+
content.format = 'org.matrix.custom.html';
794+
content.formatted_body = `${htmlPrefix}${plainText}`;
795+
}
796+
}
797+
757798
if (replyDraft) {
758799
content['m.relates_to'] = getReplyContent(replyDraft, room);
759800
}
@@ -855,7 +896,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
855896
canSendReaction,
856897
mx,
857898
roomId,
858-
threadRootId,
859899
replyDraft,
860900
silentReply,
861901
scheduledTime,
@@ -864,12 +904,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
864904
handleQuickReact,
865905
commands,
866906
sendTypingStatus,
907+
room,
867908
queryClient,
909+
threadRootId,
868910
setReplyDraft,
869911
isEncrypted,
870912
setEditingScheduledDelayId,
871913
setScheduledTime,
872-
room,
873914
]);
874915

875916
const handleKeyDown: KeyboardEventHandler = useCallback(

src/app/features/room/message/Message.tsx

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ import {
8585
addStickerToDefaultPack,
8686
doesStickerExistInDefaultPack,
8787
} from '$utils/addStickerToDefaultStickerPack';
88+
import {
89+
convertBeeperFormatToOurPerMessageProfile,
90+
PerMessageProfileBeeperFormat,
91+
} from '$hooks/usePerMessageProfile';
8892
import { MessageEditor } from './MessageEditor';
8993
import * as css from './styles.css';
9094

@@ -280,6 +284,10 @@ function useMobileDoubleTap(callback: () => void, delay = 300) {
280284
);
281285
}
282286

287+
/**
288+
* Component to render pronouns in the chat timeline.
289+
* It also filters them.
290+
*/
283291
const Pronouns = as<
284292
'span',
285293
{
@@ -295,6 +303,13 @@ const Pronouns = as<
295303
.map((lang) => lang.trim().toLowerCase())
296304
.filter(Boolean);
297305

306+
/**
307+
* filter the pronouns based on the user's language settings.
308+
* If filtering is enabled, only show pronouns that match the selected languages.
309+
* 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.
310+
* If there are multiple sets of pronouns and filtering is enabled, only show the ones that match the selected languages.
311+
* If there are no pronouns that match the selected languages, show all pronouns.
312+
*/
298313
const visiblePronouns = filterPronounsByLanguage(
299314
pronouns,
300315
languageFilterEnabled,
@@ -365,22 +380,42 @@ function MessageInternal(
365380
const mx = useMatrixClient();
366381
const useAuthentication = useMediaAuthentication();
367382

368-
const pmp = useMemo(
383+
/**
384+
* We read the per-message profile from the event content here.
385+
* 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.
386+
* 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.
387+
*/
388+
const pmp: PerMessageProfileBeeperFormat | undefined = useMemo(
369389
() =>
370390
mEvent.event.content?.['com.beeper.per_message_profile'] as
371-
| {
372-
avatar_url: string | undefined;
373-
displayname: string | undefined;
374-
id: string | undefined;
375-
}
391+
| PerMessageProfileBeeperFormat
376392
| undefined,
377393
[mEvent]
378394
);
379395

396+
/**
397+
* We convert the per-message profile from the Beeper format to our internal format here in the message component
398+
*/
399+
const parsedPMPContent = useMemo(() => {
400+
if (!pmp) return undefined;
401+
return convertBeeperFormatToOurPerMessageProfile(pmp);
402+
}, [pmp]);
403+
404+
/**
405+
* boolean to indicate wheather we should indicate to the user that it is a pmp
406+
*/
407+
const showPmPInfo = pmp !== undefined;
380408
// Profiles and Colors
381409
const profile = useUserProfile(senderId, room);
382410
const { color: usernameColor, font: usernameFont } = useSableCosmetics(senderId, room);
383411

412+
/**
413+
* If there is a per-message profile, we want to use the per message pronouns,
414+
* otherwise we fall back to the profile pronouns.
415+
* 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.
416+
*/
417+
const pronouns = parsedPMPContent?.pronouns ?? profile.pronouns;
418+
384419
const [highlightMentions] = useSetting(settingsAtom, 'highlightMentions');
385420

386421
// Avatars
@@ -431,7 +466,7 @@ function MessageInternal(
431466
}, [pmp, senderDisplayName, parsePronouns]);
432467

433468
const mergedPronouns = useMemo(() => {
434-
const existing = profile.pronouns ? [...profile.pronouns] : [];
469+
const existing = pronouns ? [...pronouns] : [];
435470

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

447482
return existing;
448-
}, [profile.pronouns, inlinePronoun]);
483+
}, [pronouns, inlinePronoun]);
449484

450485
useEffect(() => {
451486
if (!mobileOptionsOpen) return undefined;
@@ -484,6 +519,26 @@ function MessageInternal(
484519
{showPronouns && (
485520
<Pronouns pronouns={mergedPronouns} tagColor={usernameColor ?? 'currentColor'} />
486521
)}
522+
{showPmPInfo && (
523+
<Box>
524+
<Text as="span">
525+
<Text
526+
as="span"
527+
style={{ paddingLeft: 0, paddingRight: 5, fontWeight: 100, fontSize: 11 }}
528+
>
529+
via
530+
</Text>
531+
<Text
532+
as="span"
533+
size={messageLayout === MessageLayout.Bubble ? 'T300' : 'T400'}
534+
style={{ fontSize: 11 }}
535+
truncate
536+
>
537+
<UsernameBold>{senderDisplayName}</UsernameBold>
538+
</Text>
539+
</Text>
540+
</Box>
541+
)}
487542
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
488543
</Box>
489544
<Box shrink="No" gap="100">

0 commit comments

Comments
 (0)