diff --git a/cmd/gomuks/media.go b/cmd/gomuks/media.go index df56c6a..409b27a 100644 --- a/cmd/gomuks/media.go +++ b/cmd/gomuks/media.go @@ -113,6 +113,46 @@ func (new *noErrorWriter) Write(p []byte) (n int, err error) { return } +// note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts +const fallbackAvatarTemplate = ` + + %s +` + +type avatarResponseWriter struct { + http.ResponseWriter + bgColor string + character string + data []byte + errored bool +} + +func (w *avatarResponseWriter) WriteHeader(statusCode int) { + if statusCode != http.StatusOK { + w.data = []byte(fmt.Sprintf(fallbackAvatarTemplate, w.bgColor, w.character)) + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Content-Length", strconv.Itoa(len(w.data))) + w.Header().Del("Content-Disposition") + w.errored = true + statusCode = http.StatusOK + } + w.ResponseWriter.WriteHeader(statusCode) +} + +func (w *avatarResponseWriter) Write(p []byte) (n int, err error) { + if w.errored { + if w.data != nil { + _, err = w.ResponseWriter.Write(w.data) + w.data = nil + } + n = len(p) + return + } + return w.ResponseWriter.Write(p) +} + func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) { mxc := id.ContentURI{ Homeserver: r.PathValue("server"), @@ -123,6 +163,18 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) { return } query := r.URL.Query() + fallback := query.Get("fallback") + if fallback != "" { + fallbackParts := strings.Split(fallback, ":") + if len(fallbackParts) == 2 { + w = &avatarResponseWriter{ + ResponseWriter: w, + bgColor: fallbackParts[0], + character: fallbackParts[1], + } + } + } + encrypted, _ := strconv.ParseBool(query.Get("encrypted")) logVal := zerolog.Ctx(r.Context()).With(). diff --git a/web/src/api/media.ts b/web/src/api/media.ts index 933c654..ec3d2b2 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { parseMXC } from "@/util/validation.ts" -import { UserID } from "./types" +import { MemberEventContent, UserID } from "./types" export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => { const [server, mediaID] = parseMXC(mxc) @@ -28,12 +28,31 @@ export const getEncryptedMediaURL = (mxc?: string): string | undefined => { return getMediaURL(mxc, true) } -export const getAvatarURL = (_userID: UserID, mxc?: string): string | undefined => { - const [server, mediaID] = parseMXC(mxc) - if (!mediaID) { - return undefined - // return `_gomuks/avatar/${encodeURIComponent(userID)}` - } - return `_gomuks/media/${server}/${mediaID}` - // return `_gomuks/avatar/${encodeURIComponent(userID)}/${server}/${mediaID}` +const fallbackColors = [ + "#a4041d", "#9b2200", "#803f00", "#005f00", + "#005c45", "#00548c", "#064ab1", "#5d26cd", + "#822198", "#9f0850", +] + +// note: this should stay in sync with fallbackAvatarTemplate in cmd/gomuks.media.go +function makeFallbackAvatar(backgroundColor: string, fallbackCharacter: string): string { + return "data:image/svg+xml," + encodeURIComponent(` + + ${fallbackCharacter} +`) + +} + +export const getAvatarURL = (userID: UserID, content?: Partial): string | undefined => { + const fallbackCharacter = (content?.displayname?.[0]?.toUpperCase() ?? userID[1].toUpperCase()).toWellFormed() + const charCodeSum = userID.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0) + const backgroundColor = fallbackColors[charCodeSum % fallbackColors.length] + const [server, mediaID] = parseMXC(content?.avatar_url) + if (!mediaID) { + return makeFallbackAvatar(backgroundColor, fallbackCharacter) + } + const fallback = `${backgroundColor}:${fallbackCharacter}` + return `_gomuks/media/${server}/${mediaID}?encrypted=false&fallback=${encodeURIComponent(fallback)}` } diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index ef87f32..f43bc9f 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import unhomoglyph from "unhomoglyph" -import { getMediaURL } from "@/api/media.ts" +import { getAvatarURL } from "@/api/media.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { focused } from "@/util/focus.ts" import type { @@ -26,11 +26,13 @@ import type { SendCompleteData, SyncCompleteData, SyncRoom, + UserID, } from "../types" import { RoomStateStore } from "./room.ts" export interface RoomListEntry { room_id: RoomID + dm_user_id?: UserID sorting_timestamp: number preview_event?: MemDBEvent preview_sender?: MemDBEvent @@ -97,6 +99,8 @@ export class StateStore { const name = entry.meta.name ?? "Unnamed room" return { room_id: entry.meta.room_id, + dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1 + ? entry.meta.lazy_load_summary.heroes[0] : undefined, sorting_timestamp: entry.meta.sorting_timestamp, preview_event, preview_sender, @@ -183,7 +187,7 @@ export class StateStore { body = body.slice(0, 350) + " […]" } const memberEvt = room.getStateEvent("m.room.member", evt.sender) - const icon = `${getMediaURL(memberEvt?.content.avatar_url)}&image_auth=${this.imageAuthToken}` + const icon = `${getAvatarURL(evt.sender, memberEvt?.content)}&image_auth=${this.imageAuthToken}` const roomName = room.meta.current.name ?? "Unnamed room" const senderName = memberEvt?.content.displayname ?? evt.sender const title = senderName === roomName ? senderName : `${senderName} (${roomName})` diff --git a/web/src/ui/roomlist/Entry.tsx b/web/src/ui/roomlist/Entry.tsx index 4074e6f..1be292e 100644 --- a/web/src/ui/roomlist/Entry.tsx +++ b/web/src/ui/roomlist/Entry.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { memo, use, useLayoutEffect, useRef, useState } from "react" -import { getMediaURL } from "@/api/media.ts" +import { getAvatarURL } from "@/api/media.ts" import type { RoomListEntry } from "@/api/statestore" import type { MemDBEvent, MemberEventContent } from "@/api/types" import { ClientContext } from "../ClientContext.ts" @@ -54,7 +54,12 @@ const EntryInner = ({ room }: InnerProps) => { const [previewText, croppedPreviewText] = usePreviewText(room.preview_event, room.preview_sender) return <>
- +
{room.name}
diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index d5e6857..48ece9f 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -78,7 +78,7 @@ export const ReplyBody = ({ room, event, onClose, isThread }: ReplyBodyProps) =>
diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 689b0db..e349296 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -98,7 +98,7 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps diff --git a/web/src/ui/timeline/content/MemberBody.tsx b/web/src/ui/timeline/content/MemberBody.tsx index 89779a9..7840724 100644 --- a/web/src/ui/timeline/content/MemberBody.tsx +++ b/web/src/ui/timeline/content/MemberBody.tsx @@ -25,7 +25,7 @@ function useChangeDescription( const targetAvatar = @@ -50,7 +50,7 @@ function useChangeDescription( className="small avatar" loading="lazy" height={16} - src={getAvatarURL(target, prevContent?.avatar_url)} + src={getAvatarURL(target, prevContent)} onClick={use(LightboxContext)!} alt="" /> to {targetAvatar} diff --git a/web/tsconfig.json b/web/tsconfig.json index 08c0723..8532e35 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { - "target": "ES2023", + "target": "ESNext", "useDefineForClassFields": true, "lib": [ - "ES2023", + "ESNext", "DOM", "DOM.Iterable" ],