Private
Public Access
Site-wide, any user can upload -- usable both as reactions and inline in message text via :shortcode:, alongside the existing built-in Unicode picker. A :shortcode: reference is stored/sent as literal text (same as the built-in shortcode convention) and resolved to an image at render time, so it degrades to plain text if the emoji is later deleted. Backend: new custom_emoji table (shortcode unique, sized to fit MessageReaction.emoji's existing column alongside its colons), upload/ list/delete endpoints (delete restricted to uploader or site admin). Frontend: a CustomEmojiProvider context feeds a new "Custom" category in the emoji picker (inline upload + hover-to-remove), extends the composer's shortcode autocomplete, and a shared EmojiGlyph resolver renders custom emoji wherever a value can appear -- message text, reaction pills, and the picker itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
48 lines
1.4 KiB
TypeScript
48 lines
1.4 KiB
TypeScript
import { apiFetch, ApiError, NetworkError } from './client'
|
|
import type { CustomEmoji } from '../types'
|
|
|
|
export function listCustomEmoji(): Promise<CustomEmoji[]> {
|
|
return apiFetch<CustomEmoji[]>('/api/custom-emoji')
|
|
}
|
|
|
|
export function getCustomEmojiUrl(shortcode: string): string {
|
|
return `/api/custom-emoji/${encodeURIComponent(shortcode)}/image`
|
|
}
|
|
|
|
export function deleteCustomEmoji(id: string): Promise<void> {
|
|
return apiFetch<void>(`/api/custom-emoji/${id}`, { method: 'DELETE' })
|
|
}
|
|
|
|
// Raw fetch, not apiFetch -- same multipart-boundary reason as
|
|
// uploadAvatar/uploadRoomImage (a manually-set Content-Type header would
|
|
// omit the boundary the browser generates for FormData).
|
|
export async function uploadCustomEmoji(shortcode: string, file: File): Promise<CustomEmoji> {
|
|
const formData = new FormData()
|
|
formData.append('shortcode', shortcode)
|
|
formData.append('file', file)
|
|
|
|
let response: Response
|
|
try {
|
|
response = await fetch('/api/custom-emoji', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData,
|
|
})
|
|
} catch {
|
|
throw new NetworkError()
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let detail = response.statusText
|
|
try {
|
|
const body = await response.json()
|
|
detail = body.detail ?? detail
|
|
} catch {
|
|
// response had no JSON body
|
|
}
|
|
throw new ApiError(response.status, detail)
|
|
}
|
|
|
|
return (await response.json()) as CustomEmoji
|
|
}
|