1
0
Fork 0
forked from Mirrors/gomuks

web/timeline: render MSC4144 per-message profiles (#566)

Signed-off-by: Sumner Evans <me@sumnerevans.com>
This commit is contained in:
Sumner Evans 2025-01-06 06:08:34 -07:00 committed by GitHub
parent bdc823742e
commit 5b5df65f39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 104 additions and 15 deletions

View file

@ -81,12 +81,13 @@ function getFallbackCharacter(from: unknown, idx: number): string {
export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => { export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1) const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
const backgroundColor = getUserColor(userID) const backgroundColor = getUserColor(userID)
const [server, mediaID] = parseMXC(content?.avatar_url) const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url)
if (!mediaID) { if (!mediaID) {
return makeFallbackAvatar(backgroundColor, fallbackCharacter) return makeFallbackAvatar(backgroundColor, fallbackCharacter)
} }
const encrypted = !!content?.avatar_file
const fallback = `${backgroundColor}:${fallbackCharacter}` 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 { interface RoomForAvatarURL {

View file

@ -73,6 +73,7 @@ export interface EncryptedEventContent {
export interface UserProfile { export interface UserProfile {
displayname?: string displayname?: string
avatar_url?: ContentURI avatar_url?: ContentURI
avatar_file?: EncryptedFile
[custom: string]: unknown [custom: string]: unknown
} }
@ -177,6 +178,10 @@ export interface URLPreview {
"og:description"?: string "og:description"?: string
} }
export interface BeeperPerMessageProfile extends UserProfile {
id: string
}
export interface BaseMessageEventContent { export interface BaseMessageEventContent {
msgtype: string msgtype: string
body: string body: string
@ -189,6 +194,7 @@ export interface BaseMessageEventContent {
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string "page.codeberg.everypizza.msc4193.spoiler.reason"?: string
"m.url_previews"?: URLPreview[] "m.url_previews"?: URLPreview[]
"com.beeper.linkpreviews"?: URLPreview[] "com.beeper.linkpreviews"?: URLPreview[]
"com.beeper.per_message_profile"?: BeeperPerMessageProfile
} }
export interface TextMessageEventContent extends BaseMessageEventContent { export interface TextMessageEventContent extends BaseMessageEventContent {

View file

@ -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 { > div.buttons {
margin-left: auto; margin-left: auto;
display: flex; display: flex;

View file

@ -20,7 +20,7 @@ import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import TooltipButton from "../util/TooltipButton.tsx" 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 CloseIcon from "@/icons/close.svg?react"
import NotificationsOffIcon from "@/icons/notifications-off.svg?react" import NotificationsOffIcon from "@/icons/notifications-off.svg?react"
import NotificationsIcon from "@/icons/notifications.svg?react" import NotificationsIcon from "@/icons/notifications.svg?react"
@ -102,22 +102,47 @@ export const ReplyBody = ({
if (small) { if (small) {
classNames.push("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}`) classNames.push(`sender-color-${userColorIndex}`)
return <blockquote data-reply-to={event.event_id} className={classNames.join(" ")} onClick={onClickReply}> return <blockquote data-reply-to={event.event_id} className={classNames.join(" ")} onClick={onClickReply}>
{small && <div className="reply-spine"/>} {small && <div className="reply-spine"/>}
<div className="reply-sender"> <div className="reply-sender">
<div className="sender-avatar" title={event.sender}> <div
className="sender-avatar"
title={perMessageSender ? `${perMessageSender.id} via ${event.sender}` : event.sender}
>
<img <img
className="small avatar" className="small avatar"
loading="lazy" loading="lazy"
src={getAvatarURL(event.sender, memberEvtContent)} src={getAvatarURL(perMessageSender?.id ?? event.sender, renderMemberEvtContent)}
alt="" alt=""
/> />
</div> </div>
<span className={`event-sender sender-color-${userColorIndex}`}> <span
className={`event-sender sender-color-${userColorIndex}`}
title={perMessageSender ? perMessageSender.id : event.sender}
>
{getDisplayname(event.sender, renderMemberEvtContent)}
</span>
{perMessageSender && <div className="per-message-event-sender">
<span className="via">via</span>
<span
className={`event-sender sender-color-${getUserColorIndex(event.sender)}`}
title={event.sender}
>
{getDisplayname(event.sender, memberEvtContent)} {getDisplayname(event.sender, memberEvtContent)}
</span> </span>
</div>}
{onClose && <div className="buttons"> {onClose && <div className="buttons">
{onSetSilent && (isExplicitInThread || !isThread) && <TooltipButton {onSetSilent && (isExplicitInThread || !isThread) && <TooltipButton
tooltipText={isSilent tooltipText={isSilent

View file

@ -64,6 +64,21 @@ div.timeline-event {
cursor: var(--clickable-cursor); cursor: var(--clickable-cursor);
} }
> 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 { > span.event-time, > span.event-edited {
font-size: .7rem; font-size: .7rem;
color: var(--secondary-text-color); color: var(--secondary-text-color);

View file

@ -27,7 +27,7 @@ import { useRoomContext } from "../roomview/roomcontext.ts"
import ReadReceipts from "./ReadReceipts.tsx" import ReadReceipts from "./ReadReceipts.tsx"
import { ReplyIDBody } from "./ReplyBody.tsx" import { ReplyIDBody } from "./ReplyBody.tsx"
import URLPreviews from "./URLPreviews.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 { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import ErrorIcon from "@/icons/error.svg?react" import ErrorIcon from "@/icons/error.svg?react"
import PendingIcon from "@/icons/pending.svg?react" import PendingIcon from "@/icons/pending.svg?react"
@ -170,6 +170,18 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
replyInMessage = replyElem 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 smallAvatar = false
let renderAvatar = true let renderAvatar = true
let eventTimeOnly = false let eventTimeOnly = false
@ -183,6 +195,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
&& dateSeparator === null && dateSeparator === null
&& !replyAboveMessage && !replyAboveMessage
&& !isSmallEvent(getBodyType(prevEvt)) && !isSmallEvent(getBodyType(prevEvt))
&& prevPerMessageSender?.id === perMessageSender?.id
) { ) {
wrapperClassNames.push("same-sender") wrapperClassNames.push("same-sender")
eventTimeOnly = true eventTimeOnly = true
@ -209,7 +222,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
{replyAboveMessage} {replyAboveMessage}
{renderAvatar && <div {renderAvatar && <div
className="sender-avatar" className="sender-avatar"
title={evt.sender} title={perMessageSender ? `${perMessageSender.id} via ${evt.sender}` : evt.sender}
data-target-panel="user" data-target-panel="user"
data-target-user={evt.sender} data-target-user={evt.sender}
onClick={mainScreen.clickRightPanelOpener} onClick={mainScreen.clickRightPanelOpener}
@ -217,18 +230,30 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
<img <img
className={`${smallAvatar ? "small" : ""} avatar`} className={`${smallAvatar ? "small" : ""} avatar`}
loading="lazy" loading="lazy"
src={getAvatarURL(evt.sender, memberEvtContent)} src={getAvatarURL(perMessageSender?.id ?? evt.sender, renderMemberEvtContent)}
alt="" alt=""
/> />
</div>} </div>}
{!eventTimeOnly ? <div className="event-sender-and-time"> {!eventTimeOnly ? <div className="event-sender-and-time">
<span
className={`event-sender sender-color-${getUserColorIndex(perMessageSender?.id ?? evt.sender)}`}
data-target-user={evt.sender}
onClick={perMessageSender ? undefined : roomCtx.appendMentionToComposer}
title={perMessageSender ? perMessageSender.id : evt.sender}
>
{getDisplayname(evt.sender, renderMemberEvtContent)}
</span>
{perMessageSender && <div className="per-message-event-sender">
<span className="via">via</span>
<span <span
className={`event-sender sender-color-${getUserColorIndex(evt.sender)}`} className={`event-sender sender-color-${getUserColorIndex(evt.sender)}`}
data-target-user={evt.sender} data-target-user={evt.sender}
onClick={roomCtx.appendMentionToComposer} onClick={roomCtx.appendMentionToComposer}
title={evt.sender}
> >
{getDisplayname(evt.sender, memberEvtContent)} {getDisplayname(evt.sender, memberEvtContent)}
</span> </span>
</div>}
<span className="event-time" title={fullTime}>{shortTime}</span> <span className="event-time" title={fullTime}>{shortTime}</span>
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}> {(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
(edited at {formatShortTime(editEventTS)}) (edited at {formatShortTime(editEventTS)})

View file

@ -1,5 +1,5 @@
import React from "react" import React from "react"
import { MemDBEvent } from "@/api/types" import { BeeperPerMessageProfile, MemDBEvent, MessageEventContent } from "@/api/types"
import ACLBody from "./ACLBody.tsx" import ACLBody from "./ACLBody.tsx"
import EncryptedBody from "./EncryptedBody.tsx" import EncryptedBody from "./EncryptedBody.tsx"
import HiddenEvent from "./HiddenEvent.tsx" import HiddenEvent from "./HiddenEvent.tsx"
@ -113,3 +113,10 @@ export function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps
return false return false
} }
} }
export function getPerMessageProfile(evt: MemDBEvent | null): BeeperPerMessageProfile | undefined {
if (evt === null || evt.type !== "m.room.message" && evt.type !== "m.sticker") {
return undefined
}
return (evt.content as MessageEventContent)["com.beeper.per_message_profile"]
}