forked from Mirrors/gomuks
media,web: add support for fallback avatars
This commit is contained in:
parent
eb68f3da7c
commit
11e1eef5e2
8 changed files with 99 additions and 19 deletions
|
@ -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 = `<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) {
|
||||
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().
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// 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/>.
|
||||
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)
|
||||
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(`<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) {
|
||||
return undefined
|
||||
// return `_gomuks/avatar/${encodeURIComponent(userID)}`
|
||||
return makeFallbackAvatar(backgroundColor, fallbackCharacter)
|
||||
}
|
||||
return `_gomuks/media/${server}/${mediaID}`
|
||||
// return `_gomuks/avatar/${encodeURIComponent(userID)}/${server}/${mediaID}`
|
||||
const fallback = `${backgroundColor}:${fallbackCharacter}`
|
||||
return `_gomuks/media/${server}/${mediaID}?encrypted=false&fallback=${encodeURIComponent(fallback)}`
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// 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/>.
|
||||
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})`
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// 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/>.
|
||||
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 <>
|
||||
<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 className="room-entry-right">
|
||||
<div className="room-name">{room.name}</div>
|
||||
|
|
|
@ -78,7 +78,7 @@ export const ReplyBody = ({ room, event, onClose, isThread }: ReplyBodyProps) =>
|
|||
<img
|
||||
className="small avatar"
|
||||
loading="lazy"
|
||||
src={getAvatarURL(event.sender, memberEvtContent?.avatar_url)}
|
||||
src={getAvatarURL(event.sender, memberEvtContent)}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -98,7 +98,7 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyToRef }: TimelineEventProps
|
|||
<img
|
||||
className={`${smallAvatar ? "small" : ""} avatar`}
|
||||
loading="lazy"
|
||||
src={getAvatarURL(evt.sender, memberEvtContent?.avatar_url)}
|
||||
src={getAvatarURL(evt.sender, memberEvtContent)}
|
||||
onClick={use(LightboxContext)!}
|
||||
alt=""
|
||||
/>
|
||||
|
|
|
@ -25,7 +25,7 @@ function useChangeDescription(
|
|||
const targetAvatar = <img
|
||||
className="small avatar"
|
||||
loading="lazy"
|
||||
src={getAvatarURL(target, content?.avatar_url)}
|
||||
src={getAvatarURL(target, content)}
|
||||
onClick={use(LightboxContext)!}
|
||||
alt=""
|
||||
/>
|
||||
|
@ -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}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
|
|
Loading…
Add table
Reference in a new issue