1
0
Fork 0
forked from Mirrors/gomuks

media,web: add support for fallback avatars

This commit is contained in:
Tulir Asokan 2024-10-22 21:56:08 +03:00
parent eb68f3da7c
commit 11e1eef5e2
8 changed files with 99 additions and 19 deletions

View file

@ -113,6 +113,46 @@ func (new *noErrorWriter) Write(p []byte) (n int, err error) {
return return
} }
// note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts
const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<circle cx="500" cy="500" r="500" fill="%s"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>%s</text>
</svg>`
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) { func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
mxc := id.ContentURI{ mxc := id.ContentURI{
Homeserver: r.PathValue("server"), Homeserver: r.PathValue("server"),
@ -123,6 +163,18 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
return return
} }
query := r.URL.Query() 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")) encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
logVal := zerolog.Ctx(r.Context()).With(). logVal := zerolog.Ctx(r.Context()).With().

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { parseMXC } from "@/util/validation.ts" 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 => { export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
const [server, mediaID] = parseMXC(mxc) const [server, mediaID] = parseMXC(mxc)
@ -28,12 +28,31 @@ export const getEncryptedMediaURL = (mxc?: string): string | undefined => {
return getMediaURL(mxc, true) return getMediaURL(mxc, true)
} }
export const getAvatarURL = (_userID: UserID, mxc?: string): string | undefined => { const fallbackColors = [
const [server, mediaID] = parseMXC(mxc) "#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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<circle cx="500" cy="500" r="500" fill="${backgroundColor}"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>${fallbackCharacter}</text>
</svg>`)
}
export const getAvatarURL = (userID: UserID, content?: Partial<MemberEventContent>): 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) { if (!mediaID) {
return undefined return makeFallbackAvatar(backgroundColor, fallbackCharacter)
// return `_gomuks/avatar/${encodeURIComponent(userID)}`
} }
return `_gomuks/media/${server}/${mediaID}` const fallback = `${backgroundColor}:${fallbackCharacter}`
// return `_gomuks/avatar/${encodeURIComponent(userID)}/${server}/${mediaID}` return `_gomuks/media/${server}/${mediaID}?encrypted=false&fallback=${encodeURIComponent(fallback)}`
} }

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import unhomoglyph from "unhomoglyph" import unhomoglyph from "unhomoglyph"
import { getMediaURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
import { focused } from "@/util/focus.ts" import { focused } from "@/util/focus.ts"
import type { import type {
@ -26,11 +26,13 @@ import type {
SendCompleteData, SendCompleteData,
SyncCompleteData, SyncCompleteData,
SyncRoom, SyncRoom,
UserID,
} from "../types" } from "../types"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
export interface RoomListEntry { export interface RoomListEntry {
room_id: RoomID room_id: RoomID
dm_user_id?: UserID
sorting_timestamp: number sorting_timestamp: number
preview_event?: MemDBEvent preview_event?: MemDBEvent
preview_sender?: MemDBEvent preview_sender?: MemDBEvent
@ -97,6 +99,8 @@ export class StateStore {
const name = entry.meta.name ?? "Unnamed room" const name = entry.meta.name ?? "Unnamed room"
return { return {
room_id: entry.meta.room_id, 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, sorting_timestamp: entry.meta.sorting_timestamp,
preview_event, preview_event,
preview_sender, preview_sender,
@ -183,7 +187,7 @@ export class StateStore {
body = body.slice(0, 350) + " […]" body = body.slice(0, 350) + " […]"
} }
const memberEvt = room.getStateEvent("m.room.member", evt.sender) 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 roomName = room.meta.current.name ?? "Unnamed room"
const senderName = memberEvt?.content.displayname ?? evt.sender const senderName = memberEvt?.content.displayname ?? evt.sender
const title = senderName === roomName ? senderName : `${senderName} (${roomName})` const title = senderName === roomName ? senderName : `${senderName} (${roomName})`

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { memo, use, useLayoutEffect, useRef, useState } from "react" 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 { RoomListEntry } from "@/api/statestore"
import type { MemDBEvent, MemberEventContent } from "@/api/types" import type { MemDBEvent, MemberEventContent } from "@/api/types"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
@ -54,7 +54,12 @@ const EntryInner = ({ room }: InnerProps) => {
const [previewText, croppedPreviewText] = usePreviewText(room.preview_event, room.preview_sender) const [previewText, croppedPreviewText] = usePreviewText(room.preview_event, room.preview_sender)
return <> return <>
<div className="room-entry-left"> <div className="room-entry-left">
<img loading="lazy" className="avatar room-avatar" src={getMediaURL(room.avatar)} alt=""/> <img
loading="lazy"
className="avatar room-avatar"
src={getAvatarURL(room.dm_user_id ?? room.room_id, { avatar_url: room.avatar, displayname: room.name })}
alt=""
/>
</div> </div>
<div className="room-entry-right"> <div className="room-entry-right">
<div className="room-name">{room.name}</div> <div className="room-name">{room.name}</div>

View file

@ -78,7 +78,7 @@ export const ReplyBody = ({ room, event, onClose, isThread }: ReplyBodyProps) =>
<img <img
className="small avatar" className="small avatar"
loading="lazy" loading="lazy"
src={getAvatarURL(event.sender, memberEvtContent?.avatar_url)} src={getAvatarURL(event.sender, memberEvtContent)}
alt="" alt=""
/> />
</div> </div>

View file

@ -98,7 +98,7 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps
<img <img
className={`${smallAvatar ? "small" : ""} avatar`} className={`${smallAvatar ? "small" : ""} avatar`}
loading="lazy" loading="lazy"
src={getAvatarURL(evt.sender, memberEvtContent?.avatar_url)} src={getAvatarURL(evt.sender, memberEvtContent)}
onClick={use(LightboxContext)!} onClick={use(LightboxContext)!}
alt="" alt=""
/> />

View file

@ -25,7 +25,7 @@ function useChangeDescription(
const targetAvatar = <img const targetAvatar = <img
className="small avatar" className="small avatar"
loading="lazy" loading="lazy"
src={getAvatarURL(target, content?.avatar_url)} src={getAvatarURL(target, content)}
onClick={use(LightboxContext)!} onClick={use(LightboxContext)!}
alt="" alt=""
/> />
@ -50,7 +50,7 @@ function useChangeDescription(
className="small avatar" className="small avatar"
loading="lazy" loading="lazy"
height={16} height={16}
src={getAvatarURL(target, prevContent?.avatar_url)} src={getAvatarURL(target, prevContent)}
onClick={use(LightboxContext)!} onClick={use(LightboxContext)!}
alt="" alt=""
/> to {targetAvatar} /> to {targetAvatar}

View file

@ -1,9 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2023", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": [ "lib": [
"ES2023", "ESNext",
"DOM", "DOM",
"DOM.Iterable" "DOM.Iterable"
], ],