mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/timeline: render MSC4144 per-message profiles
Signed-off-by: Sumner Evans <me@sumnerevans.com>
This commit is contained in:
parent
cb08f43535
commit
fa52b44781
7 changed files with 89 additions and 15 deletions
|
@ -81,6 +81,11 @@ 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)
|
||||||
|
if (content?.avatar_file) {
|
||||||
|
const [server, mediaID] = parseMXC(content.avatar_file.url)
|
||||||
|
const fallback = `${backgroundColor}:${fallbackCharacter}`
|
||||||
|
return `_gomuks/media/${server}/${mediaID}?encrypted=true&fallback=${encodeURIComponent(fallback)}`
|
||||||
|
}
|
||||||
const [server, mediaID] = parseMXC(content?.avatar_url)
|
const [server, mediaID] = parseMXC(content?.avatar_url)
|
||||||
if (!mediaID) {
|
if (!mediaID) {
|
||||||
return makeFallbackAvatar(backgroundColor, fallbackCharacter)
|
return makeFallbackAvatar(backgroundColor, fallbackCharacter)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,6 +172,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
|
||||||
|
@ -183,6 +188,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 {
|
||||||
|
|
|
@ -87,6 +87,16 @@ blockquote.reply-body {
|
||||||
color: var(--secondary-text-color);
|
color: var(--secondary-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> div.reply-sender > div.per-message-event-sender {
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.3rem;
|
||||||
|
|
||||||
|
> span.via {
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> div.reply-sender {
|
> div.reply-sender {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -60,7 +60,7 @@ export const ReplyIDBody = ({ room, eventID, isThread, small }: ReplyIDBodyProps
|
||||||
<code>{eventID}</code>
|
<code>{eventID}</code>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
}
|
}
|
||||||
return <ReplyBody room={room} event={event} isThread={isThread} small={small}/>
|
return <ReplyBody room={room} event={event} isThread={isThread} small={small} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickReply = (evt: React.MouseEvent) => {
|
const onClickReply = (evt: React.MouseEvent) => {
|
||||||
|
@ -102,22 +102,32 @@ export const ReplyBody = ({
|
||||||
if (small) {
|
if (small) {
|
||||||
classNames.push("small")
|
classNames.push("small")
|
||||||
}
|
}
|
||||||
const userColorIndex = getUserColorIndex(event.sender)
|
const perMessageSender = getPerMessageProfile(event)
|
||||||
|
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, perMessageSender ?? memberEvtContent)}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={`event-sender sender-color-${userColorIndex}`}>
|
<span className={`event-sender sender-color-${userColorIndex}`}>
|
||||||
{getDisplayname(event.sender, memberEvtContent)}
|
{getDisplayname(event.sender, perMessageSender ?? memberEvtContent)}
|
||||||
</span>
|
</span>
|
||||||
|
{perMessageSender && <div className="per-message-event-sender">
|
||||||
|
<span className="via">via</span>
|
||||||
|
<span className={`event-sender sender-color-${getUserColorIndex(event.sender)}`}>
|
||||||
|
{getDisplayname(event.sender, memberEvtContent)}
|
||||||
|
</span>
|
||||||
|
</div>}
|
||||||
{onClose && <div className="buttons">
|
{onClose && <div className="buttons">
|
||||||
{onSetSilent && (isExplicitInThread || !isThread) && <TooltipButton
|
{onSetSilent && (isExplicitInThread || !isThread) && <TooltipButton
|
||||||
tooltipText={isSilent
|
tooltipText={isSilent
|
||||||
|
|
|
@ -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: 0.85rem;
|
||||||
|
|
||||||
|
> span.via {
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> 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);
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -114,6 +114,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
}
|
}
|
||||||
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
|
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
|
||||||
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
||||||
|
let renderMemberEvtContent = memberEvtContent
|
||||||
const BodyType = getBodyType(evt)
|
const BodyType = getBodyType(evt)
|
||||||
const eventTS = new Date(evt.timestamp)
|
const eventTS = new Date(evt.timestamp)
|
||||||
const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null
|
const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null
|
||||||
|
@ -170,6 +171,17 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
replyInMessage = replyElem
|
replyInMessage = replyElem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const perMessageSender = getPerMessageProfile(evt)
|
||||||
|
const prevPerMessageSender = getPerMessageProfile(prevEvt)
|
||||||
|
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,28 @@ 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
|
<span
|
||||||
className={`event-sender sender-color-${getUserColorIndex(evt.sender)}`}
|
className={`event-sender sender-color-${getUserColorIndex(perMessageSender?.id ?? evt.sender)}`}
|
||||||
data-target-user={evt.sender}
|
data-target-user={perMessageSender ? undefined : evt.sender}
|
||||||
onClick={roomCtx.appendMentionToComposer}
|
onClick={perMessageSender ? undefined : roomCtx.appendMentionToComposer}
|
||||||
>
|
>
|
||||||
{getDisplayname(evt.sender, memberEvtContent)}
|
{getDisplayname(evt.sender, renderMemberEvtContent)}
|
||||||
</span>
|
</span>
|
||||||
|
{perMessageSender && <div className="per-message-event-sender">
|
||||||
|
<span className="via">via</span>
|
||||||
|
<span
|
||||||
|
className={`event-sender sender-color-${getUserColorIndex(evt.sender)}`}
|
||||||
|
data-target-user={evt.sender}
|
||||||
|
onClick={roomCtx.appendMentionToComposer}
|
||||||
|
>
|
||||||
|
{getDisplayname(evt.sender, memberEvtContent)}
|
||||||
|
</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)})
|
||||||
|
@ -245,7 +268,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
||||||
</div>
|
</div>
|
||||||
{!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts &&
|
{!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts &&
|
||||||
<ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
|
<ReadReceipts room={roomCtx.store} eventID={evt.event_id}/>}
|
||||||
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
||||||
</div>
|
</div>
|
||||||
return <>
|
return <>
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -104,3 +104,8 @@ 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"]
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue