diff --git a/web/src/api/media.ts b/web/src/api/media.ts index 5e3180d..2c5c503 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -81,6 +81,11 @@ 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) + 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) if (!mediaID) { return makeFallbackAvatar(backgroundColor, fallbackCharacter) diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 8605239..7ba6b98 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 } @@ -171,6 +172,10 @@ export interface URLPreview { "og:description"?: string } +export interface BeeperPerMessageProfile extends UserProfile { + id: string +} + export interface BaseMessageEventContent { msgtype: string body: string @@ -183,6 +188,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..a5aa254 100644 --- a/web/src/ui/timeline/ReplyBody.css +++ b/web/src/ui/timeline/ReplyBody.css @@ -87,6 +87,16 @@ blockquote.reply-body { 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 { display: flex; align-items: center; diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index 1788baa..dac60b3 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" @@ -60,7 +60,7 @@ export const ReplyIDBody = ({ room, eventID, isThread, small }: ReplyIDBodyProps {eventID} } - return + return } const onClickReply = (evt: React.MouseEvent) => { @@ -102,22 +102,32 @@ export const ReplyBody = ({ if (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}`) return
{small &&
}
-
+
- {getDisplayname(event.sender, memberEvtContent)} + {getDisplayname(event.sender, perMessageSender ?? memberEvtContent)} + {perMessageSender &&
+ via + + {getDisplayname(event.sender, memberEvtContent)} + +
} {onClose &&
{onSetSilent && (isExplicitInThread || !isThread) && 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 { 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..5017f3e 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" @@ -114,6 +114,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T } const memberEvt = useRoomMember(client, roomCtx.store, evt.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined + let renderMemberEvtContent = memberEvtContent const BodyType = getBodyType(evt) const eventTS = new Date(evt.timestamp) 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 } } + 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 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)}) @@ -245,7 +268,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T {evt.reactions ? : null}
{!evt.event_id.startsWith("~") && roomCtx.store.preferences.display_read_receipts && - } + } {evt.sender === client.userID && evt.transaction_id ? : null}
return <> diff --git a/web/src/ui/timeline/content/index.ts b/web/src/ui/timeline/content/index.ts index 108d44a..a0a08de 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" @@ -104,3 +104,8 @@ export function isSmallEvent(bodyType: React.FunctionComponent