From 5b5df65f39bb13d70fa491b442c8bcb02bfb0ce5 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Mon, 6 Jan 2025 06:08:34 -0700 Subject: [PATCH] web/timeline: render MSC4144 per-message profiles (#566) Signed-off-by: Sumner Evans --- web/src/api/media.ts | 5 ++-- web/src/api/types/mxtypes.ts | 6 +++++ web/src/ui/timeline/ReplyBody.css | 10 ++++++++ web/src/ui/timeline/ReplyBody.tsx | 37 ++++++++++++++++++++++----- web/src/ui/timeline/TimelineEvent.css | 15 +++++++++++ web/src/ui/timeline/TimelineEvent.tsx | 37 ++++++++++++++++++++++----- web/src/ui/timeline/content/index.ts | 9 ++++++- 7 files changed, 104 insertions(+), 15 deletions(-) diff --git a/web/src/api/media.ts b/web/src/api/media.ts index 5e3180d..028ab6f 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -81,12 +81,13 @@ function getFallbackCharacter(from: unknown, idx: number): string { export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => { const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1) const backgroundColor = getUserColor(userID) - const [server, mediaID] = parseMXC(content?.avatar_url) + const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url) if (!mediaID) { return makeFallbackAvatar(backgroundColor, fallbackCharacter) } + const encrypted = !!content?.avatar_file const fallback = `${backgroundColor}:${fallbackCharacter}` - return `_gomuks/media/${server}/${mediaID}?encrypted=false&fallback=${encodeURIComponent(fallback)}` + return `_gomuks/media/${server}/${mediaID}?encrypted=${encrypted}&fallback=${encodeURIComponent(fallback)}` } interface RoomForAvatarURL { diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 77f036a..51a22d4 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -73,6 +73,7 @@ export interface EncryptedEventContent { export interface UserProfile { displayname?: string avatar_url?: ContentURI + avatar_file?: EncryptedFile [custom: string]: unknown } @@ -177,6 +178,10 @@ export interface URLPreview { "og:description"?: string } +export interface BeeperPerMessageProfile extends UserProfile { + id: string +} + export interface BaseMessageEventContent { msgtype: string body: string @@ -189,6 +194,7 @@ export interface BaseMessageEventContent { "page.codeberg.everypizza.msc4193.spoiler.reason"?: string "m.url_previews"?: URLPreview[] "com.beeper.linkpreviews"?: URLPreview[] + "com.beeper.per_message_profile"?: BeeperPerMessageProfile } export interface TextMessageEventContent extends BaseMessageEventContent { diff --git a/web/src/ui/timeline/ReplyBody.css b/web/src/ui/timeline/ReplyBody.css index 391b456..d932627 100644 --- a/web/src/ui/timeline/ReplyBody.css +++ b/web/src/ui/timeline/ReplyBody.css @@ -103,6 +103,16 @@ blockquote.reply-body { } } + > div.per-message-event-sender { + color: var(--secondary-text-color); + font-size: .75rem; + margin: 0 .25rem; + + > span.via { + margin-right: .25rem; + } + } + > div.buttons { margin-left: auto; display: flex; diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index 1788baa..65c20c8 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -20,7 +20,7 @@ import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types" import { getDisplayname } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" import TooltipButton from "../util/TooltipButton.tsx" -import { ContentErrorBoundary, getBodyType } from "./content" +import { ContentErrorBoundary, getBodyType, getPerMessageProfile } from "./content" import CloseIcon from "@/icons/close.svg?react" import NotificationsOffIcon from "@/icons/notifications-off.svg?react" import NotificationsIcon from "@/icons/notifications.svg?react" @@ -102,22 +102,47 @@ export const ReplyBody = ({ if (small) { classNames.push("small") } - const userColorIndex = getUserColorIndex(event.sender) + const perMessageSender = getPerMessageProfile(event) + let renderMemberEvtContent = memberEvtContent + if (perMessageSender) { + renderMemberEvtContent = { + membership: "join", + displayname: perMessageSender.displayname ?? memberEvtContent?.displayname, + avatar_url: perMessageSender.avatar_url ?? memberEvtContent?.avatar_url, + avatar_file: perMessageSender.avatar_file ?? memberEvtContent?.avatar_file, + } + } + const userColorIndex = getUserColorIndex(perMessageSender?.id ?? event.sender) classNames.push(`sender-color-${userColorIndex}`) return
{small &&
}
-
+
- - {getDisplayname(event.sender, memberEvtContent)} + + {getDisplayname(event.sender, renderMemberEvtContent)} + {perMessageSender &&
+ via + + {getDisplayname(event.sender, memberEvtContent)} + +
} {onClose &&
{onSetSilent && (isExplicitInThread || !isThread) && div.per-message-event-sender { + color: var(--secondary-text-color); + font-size: .75rem; + + > span.via { + margin-right: .25rem; + } + + > span.event-sender { + font-weight: bold; + user-select: none; + cursor: var(--clickable-cursor); + } + } + > span.event-time, > span.event-edited { font-size: .7rem; color: var(--secondary-text-color); diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 524d84b..55c83ed 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -27,7 +27,7 @@ import { useRoomContext } from "../roomview/roomcontext.ts" import ReadReceipts from "./ReadReceipts.tsx" import { ReplyIDBody } from "./ReplyBody.tsx" import URLPreviews from "./URLPreviews.tsx" -import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content" +import { ContentErrorBoundary, HiddenEvent, getBodyType, getPerMessageProfile, isSmallEvent } from "./content" import { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu" import ErrorIcon from "@/icons/error.svg?react" import PendingIcon from "@/icons/pending.svg?react" @@ -170,6 +170,18 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T replyInMessage = replyElem } } + const perMessageSender = getPerMessageProfile(evt) + const prevPerMessageSender = getPerMessageProfile(prevEvt) + let renderMemberEvtContent = memberEvtContent + if (perMessageSender) { + renderMemberEvtContent = { + membership: "join", + displayname: perMessageSender.displayname ?? memberEvtContent?.displayname, + avatar_url: perMessageSender.avatar_url ?? memberEvtContent?.avatar_url, + avatar_file: perMessageSender.avatar_file ?? memberEvtContent?.avatar_file, + } + } + let smallAvatar = false let renderAvatar = true let eventTimeOnly = false @@ -183,6 +195,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T && dateSeparator === null && !replyAboveMessage && !isSmallEvent(getBodyType(prevEvt)) + && prevPerMessageSender?.id === perMessageSender?.id ) { wrapperClassNames.push("same-sender") eventTimeOnly = true @@ -209,7 +222,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T {replyAboveMessage} {renderAvatar &&
} {!eventTimeOnly ?
- {getDisplayname(evt.sender, memberEvtContent)} + {getDisplayname(evt.sender, renderMemberEvtContent)} + {perMessageSender &&
+ via + + {getDisplayname(evt.sender, memberEvtContent)} + +
} {shortTime} {(editEventTS && editTime) ? (edited at {formatShortTime(editEventTS)}) diff --git a/web/src/ui/timeline/content/index.ts b/web/src/ui/timeline/content/index.ts index b3c77f7..7bd981b 100644 --- a/web/src/ui/timeline/content/index.ts +++ b/web/src/ui/timeline/content/index.ts @@ -1,5 +1,5 @@ import React from "react" -import { MemDBEvent } from "@/api/types" +import { BeeperPerMessageProfile, MemDBEvent, MessageEventContent } from "@/api/types" import ACLBody from "./ACLBody.tsx" import EncryptedBody from "./EncryptedBody.tsx" import HiddenEvent from "./HiddenEvent.tsx" @@ -113,3 +113,10 @@ export function isSmallEvent(bodyType: React.FunctionComponent