mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
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
|
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().
|
||||||
|
|
|
@ -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",
|
||||||
if (!mediaID) {
|
"#005c45", "#00548c", "#064ab1", "#5d26cd",
|
||||||
return undefined
|
"#822198", "#9f0850",
|
||||||
// return `_gomuks/avatar/${encodeURIComponent(userID)}`
|
]
|
||||||
}
|
|
||||||
return `_gomuks/media/${server}/${mediaID}`
|
// note: this should stay in sync with fallbackAvatarTemplate in cmd/gomuks.media.go
|
||||||
// return `_gomuks/avatar/${encodeURIComponent(userID)}/${server}/${mediaID}`
|
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 makeFallbackAvatar(backgroundColor, fallbackCharacter)
|
||||||
|
}
|
||||||
|
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
|
// 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})`
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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=""
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2023",
|
"target": "ESNext",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2023",
|
"ESNext",
|
||||||
"DOM",
|
"DOM",
|
||||||
"DOM.Iterable"
|
"DOM.Iterable"
|
||||||
],
|
],
|
||||||
|
|
Loading…
Add table
Reference in a new issue