From 5c861afcda382f113e0ceb4f35cfc09ee8c23c20 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 5 Oct 2026 19:40:41 +0200 Subject: [PATCH] Use custom emoji from your other servers, where the server allows it (GRYT-1660) The owner gets a switch under server settings, "Let members use custom emoji from their other servers", shown once the server knows the setting (server#280). When it's on, the chat picker lists each other server's emoji under that server's address, and picking one puts the image itself in the message, since its name only means something on its own server. Messages there then draw it; with the switch off they show its name. Reactions don't offer them: a reaction is stored by name. The name expansion now skips image markdown, so a server with an emoji of the same name can't rewrite the alt text inside one. Co-Authored-By: Claude Opus 5.5 --- src/packages/settings/src/types/server.ts | 2 ++ .../socket/src/components/ChatEditor.tsx | 17 ++++++++++-- .../socket/src/components/EmojiPicker.tsx | 25 ++++++++++++----- .../src/components/MarkdownRenderer.tsx | 11 +++++--- .../src/components/ServerOverviewTab.tsx | 27 +++++++++++++++++++ .../src/hooks/registerServerSocketEvents.ts | 3 +++ src/packages/socket/src/utils/emojiData.ts | 10 +++++++ .../socket/src/utils/externalEmojiPolicy.ts | 27 +++++++++++++++++++ src/packages/socket/src/utils/remarkEmoji.ts | 16 ++++++----- 9 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 src/packages/socket/src/utils/externalEmojiPolicy.ts diff --git a/src/packages/settings/src/types/server.ts b/src/packages/settings/src/types/server.ts index fbc0be96..78b16f03 100644 --- a/src/packages/settings/src/types/server.ts +++ b/src/packages/settings/src/types/server.ts @@ -120,6 +120,8 @@ export type serverDetails = { uploads_checked?: boolean; /** Video avatars and banners are taken here: the host can and the owner allows it. */ video_profiles?: boolean; + /** Messages here may show other Gryt servers' custom emoji. */ + external_emojis?: boolean; version?: string; /** * Every plugin this server is running, and what each may do. All of them — an diff --git a/src/packages/socket/src/components/ChatEditor.tsx b/src/packages/socket/src/components/ChatEditor.tsx index 1adf4e6b..ea3eca9b 100644 --- a/src/packages/socket/src/components/ChatEditor.tsx +++ b/src/packages/socket/src/components/ChatEditor.tsx @@ -99,7 +99,8 @@ function serializeContentEditable(el: HTMLElement): string { } else if (node.nodeType === Node.ELEMENT_NODE) { const elem = node as HTMLElement; if (elem.tagName === "IMG" && elem.dataset.emojiName) { - result += `:${elem.dataset.emojiName}:`; + // Another server's emoji goes as its image; its name only resolves on its own server. + result += elem.dataset.emojiSrc ? `![:${elem.dataset.emojiName}:](${elem.dataset.emojiSrc})` : `:${elem.dataset.emojiName}:`; } else if (elem.dataset.roleId) { result += `[${elem.dataset.mentionName || elem.textContent || ""}](role:${elem.dataset.roleId})`; } else if (elem.dataset.channelId) { @@ -497,7 +498,18 @@ export const ChatEditor = forwardRef( el.focus(); const sel = window.getSelection(); - if (src.startsWith(":") && src.endsWith(":")) { + const other = /^!\[:([a-zA-Z0-9_+-]+):\]\((https?:\/\/[^)\s]+)\)$/.exec(src); + if (other) { + const img = document.createElement("img"); + img.src = other[2]; + img.alt = `:${other[1]}:`; + img.dataset.emojiName = other[1]; + img.dataset.emojiSrc = other[2]; + img.className = "inline-emoji"; + img.draggable = false; + img.contentEditable = "false"; + el.appendChild(img); + } else if (src.startsWith(":") && src.endsWith(":")) { const name = src.slice(1, -1); const custom = getCustomEmojis().find((e) => e.name === name); if (custom?.url) { @@ -721,6 +733,7 @@ export const ChatEditor = forwardRef( onSelect={handlePickerEmojiSelect} onClose={() => setEmojiPickerOpen(false)} serverHost={serverHost} + withOtherServers /> )} diff --git a/src/packages/socket/src/components/EmojiPicker.tsx b/src/packages/socket/src/components/EmojiPicker.tsx index 24d90881..390a36c9 100644 --- a/src/packages/socket/src/components/EmojiPicker.tsx +++ b/src/packages/socket/src/components/EmojiPicker.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, use import { useCustomThemes, useTheme } from "@/common"; -import { type EmojiEntry, getCustomEmojis, getStandardEmojisByCategory, onCustomEmojisChange } from "../utils/emojiData"; +import { type EmojiEntry, externalEmojiMarkdown, getCustomEmojis, getOtherServersEmojis, getStandardEmojisByCategory, onCustomEmojisChange } from "../utils/emojiData"; +import { useExternalEmojisAllowed } from "../utils/externalEmojiPolicy"; import { getRecentReactions } from "../utils/recentReactions"; interface EmojiPickerProps { @@ -12,11 +13,14 @@ interface EmojiPickerProps { anchorEl?: HTMLElement | null; placement?: "above" | "beside"; serverHost?: string; + /** Offers other servers' emoji where this one allows them. Not for reactions, which go by name. */ + withOtherServers?: boolean; } export interface EmojiPickerContentProps { onSelect: (reactionSrc: string) => void; serverHost?: string; + withOtherServers?: boolean; autoFocusSearch?: boolean; } @@ -32,8 +36,10 @@ function pickerItem(entry: EmojiEntry): EmojiPickerItem { keywords: [entry.name, ...entry.aliases, ...entry.tags] }; } -export function EmojiPickerContent({ onSelect, serverHost, autoFocusSearch = true }: EmojiPickerContentProps) { +export function EmojiPickerContent({ onSelect, serverHost, withOtherServers = false, autoFocusSearch = true }: EmojiPickerContentProps) { const custom = useSyncExternalStore(onCustomEmojisChange, () => getCustomEmojis(serverHost)); + const allowed = useExternalEmojisAllowed(serverHost); + const others = useMemo(() => (withOtherServers && allowed ? getOtherServersEmojis(serverHost) : []), [withOtherServers, allowed, serverHost, custom]); // eslint-disable-line react-hooks/exhaustive-deps const { activeTheme } = useCustomThemes(); const { resolvedAppearance } = useTheme(); const theme = useMemo(() => createGrytTheme(grytThemeToOptions(activeTheme ?? grytTheme, resolvedAppearance)), [activeTheme, resolvedAppearance]); @@ -47,9 +53,14 @@ export function EmojiPickerContent({ onSelect, serverHost, autoFocusSearch = tru return [ { id: "server", label: "This server", items: custom.map(pickerItem) }, { id: "recent", label: "Recently Used", items: recent }, + ...others.map(([host, entries]) => ({ + id: `other:${host}`, + label: host, + items: entries.map((e) => ({ ...pickerItem(e), id: `other:${externalEmojiMarkdown(e)}` })), + })), ...Array.from(standard, ([label, entries]) => ({ id: label, label, items: entries.map(pickerItem) })), ]; - }, [custom, serverHost]); + }, [custom, serverHost, others]); return ( { if (event.target instanceof HTMLInputElement) event.stopPropagation(); }} - onSelect={(item) => onSelect(item.id.startsWith("server:") ? `:${item.id.slice(7)}:` : item.emoji ?? "")} + onSelect={(item) => onSelect( + item.id.startsWith("server:") ? `:${item.id.slice(7)}:` : item.id.startsWith("other:") ? item.id.slice(6) : item.emoji ?? "", + )} /> ); } -export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above", serverHost }: EmojiPickerProps) => { +export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above", serverHost, withOtherServers }: EmojiPickerProps) => { const containerRef = useRef(null); const [fixedPos, setFixedPos] = useState<{ top?: number; bottom?: number; left: number }>({ left: 0 }); useLayoutEffect(() => { @@ -131,7 +144,7 @@ export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above",
event.stopPropagation()}> - +
); }; diff --git a/src/packages/socket/src/components/MarkdownRenderer.tsx b/src/packages/socket/src/components/MarkdownRenderer.tsx index 686841b2..c0950512 100644 --- a/src/packages/socket/src/components/MarkdownRenderer.tsx +++ b/src/packages/socket/src/components/MarkdownRenderer.tsx @@ -9,6 +9,7 @@ import remarkGfm from "remark-gfm"; import { getServerAccessToken, getServerHttpBase, useTheme } from "@/common"; import { PiCheck, PiCopyFill } from "../../../../lib/icons"; +import { useExternalEmojisAllowed } from "../utils/externalEmojiPolicy"; import { type CustomEmojiEntry, preprocessCustomEmojis, preprocessSmileys, remarkEmoji } from "../utils/remarkEmoji"; import { createRemarkMention } from "../utils/remarkMention"; import type { ProfanityMatchRange } from "./chatUtils"; @@ -375,7 +376,7 @@ export const MarkdownRenderer = memo(({ memberNicknames, mentionMembersById, serverHost, - allowExternalEmojis = false, + allowExternalEmojis, profanityMatches, blurProfanity, smileyConversion = true, @@ -386,7 +387,7 @@ export const MarkdownRenderer = memo(({ memberNicknames?: string[]; mentionMembersById?: Record; serverHost?: string | null; - /** The server lets other Gryt servers' emoji show in its messages. */ + /** Overrides what the server said about other servers' emoji, for previews. */ allowExternalEmojis?: boolean; profanityMatches?: ProfanityMatchRange[]; blurProfanity?: boolean; @@ -394,6 +395,8 @@ export const MarkdownRenderer = memo(({ disabledSmileys?: ReadonlySet; }) => { const { emojiSize } = useTheme(); + const serverAllows = useExternalEmojisAllowed(serverHost); + const externalOk = allowExternalEmojis ?? serverAllows; const emojiOnly = useMemo(() => content ? isEmojiOnly(content) : false, [content]); const hasProfanity = !!(blurProfanity && profanityMatches && profanityMatches.length > 0); @@ -441,7 +444,7 @@ export const MarkdownRenderer = memo(({ const local = !!src && imageIsLocal(src, serverHost ?? null); if (isCustomEmoji) { // An emoji that may not load here reads as its name, which is what was typed. - if (!src || (!local && !(allowExternalEmojis && isGrytEmojiUrl(src)))) return <>{alt || ""}; + if (!src || (!local && !(externalOk && isGrytEmojiUrl(src)))) return <>{alt || ""}; const emojiId = (props["data-emoji-name"] ? `:${props["data-emoji-name"]}:` : null) ?? (alt && /^:[a-zA-Z0-9_+-]+:$/.test(alt) ? alt : null) @@ -533,7 +536,7 @@ export const MarkdownRenderer = memo(({ td: wrap(base.td as React.FC<{ children?: React.ReactNode }>), th: wrap(base.th as React.FC<{ children?: React.ReactNode }>), } as Components; - }, [hasProfanity, membersById, serverHost, allowExternalEmojis]); + }, [hasProfanity, membersById, serverHost, externalOk]); if (!processed) return null; diff --git a/src/packages/socket/src/components/ServerOverviewTab.tsx b/src/packages/socket/src/components/ServerOverviewTab.tsx index 227e283d..ce32314d 100644 --- a/src/packages/socket/src/components/ServerOverviewTab.tsx +++ b/src/packages/socket/src/components/ServerOverviewTab.tsx @@ -49,6 +49,8 @@ type ServerSettingsPayload = { spamFilter?: boolean; spamSensitivity?: SpamSensitivity; videoProfiles?: boolean; + /** Absent on a server from before the setting, which then shows no switch. */ + externalEmojis?: boolean; /** What this host can do with uploads; an older server sends nothing. */ media?: { checked?: boolean; video?: boolean }; }; @@ -106,6 +108,7 @@ export function ServerOverviewTab({ const [spamFilter, setSpamFilter] = useState(true); const [spamSensitivity, setSpamSensitivity] = useState("normal"); const [videoProfiles, setVideoProfiles] = useState(true); + const [externalEmojis, setExternalEmojis] = useState(null); const [media, setMedia] = useState<{ checked: boolean; video: boolean } | null>(null); const [autosaving, setAutosaving] = useState(false); @@ -125,6 +128,7 @@ export function ServerOverviewTab({ spamFilter: boolean; spamSensitivity: SpamSensitivity; videoProfiles: boolean; + externalEmojis: boolean; } | null>(null); const [avatarMaxMb, setAvatarMaxMb] = useState(""); @@ -183,6 +187,7 @@ export function ServerOverviewTab({ setSpamFilter(payload.spamFilter !== false); setSpamSensitivity(normalizeSpamSensitivity(payload.spamSensitivity)); setVideoProfiles(payload.videoProfiles !== false); + setExternalEmojis(typeof payload.externalEmojis === "boolean" ? payload.externalEmojis : null); setMedia(payload.media ? { checked: !!payload.media.checked, video: !!payload.media.video } : null); if (!wasSaving) { @@ -213,6 +218,7 @@ export function ServerOverviewTab({ spamFilter: payload.spamFilter !== false, spamSensitivity: normalizeSpamSensitivity(payload.spamSensitivity), videoProfiles: payload.videoProfiles !== false, + externalEmojis: payload.externalEmojis === true, }; }; @@ -275,6 +281,7 @@ export function ServerOverviewTab({ spamFilter: boolean; spamSensitivity: SpamSensitivity; videoProfiles: boolean; + externalEmojis: boolean; }>): boolean => { if (!host || !socket || !socket.connected) { toast.error("Not connected to the server."); @@ -594,6 +601,26 @@ export function ServerOverviewTab({ )} + {externalEmojis !== null && ( +
+ Emoji from other servers +
+ { + setExternalEmojis(v); + if (!saveIfChanged({ externalEmojis: v })) setExternalEmojis(!v); + }} + disabled={!canEdit} + /> + Let members use custom emoji from their other servers +
+ + Each one loads from the server it belongs to, so that server sees who reads it, and this server can't check what the picture shows. + +
+ )} +
Limits (optional) diff --git a/src/packages/socket/src/hooks/registerServerSocketEvents.ts b/src/packages/socket/src/hooks/registerServerSocketEvents.ts index 920c35da..59c1fa1b 100644 --- a/src/packages/socket/src/hooks/registerServerSocketEvents.ts +++ b/src/packages/socket/src/hooks/registerServerSocketEvents.ts @@ -56,6 +56,7 @@ import { attachServerMls, serverMlsPinned, serverMlsReady } from "../mls/serverM import { Clients, ServerProfile } from "../types/clients"; import { publishDmKey } from "../utils/dmKeys"; import { fetchCustomEmojis, setCustomEmojis } from "../utils/emojiData"; +import { setExternalEmojisAllowed } from "../utils/externalEmojiPolicy"; import { firstTimeOnThisSocket } from "../utils/publishedOnce"; import { handleRateLimitError } from "../utils/rateLimitHandler"; import { syncAvatarToHost } from "../utils/syncAvatarToHost"; @@ -241,6 +242,8 @@ export function registerServerSocketEvents(socket: Socket, host: string, ctx: Se }); socket.on("server:details", (data: serverDetails) => { + // An older server says nothing, which reads as off. + setExternalEmojisAllowed(host, data.server_info?.external_emojis === true); /* Where the sidebar arrives, and the layer deciding whether a message makes a noise has no other way to know a channel's folder. Before the join check. */ if (Array.isArray(data.sidebar_items)) { diff --git a/src/packages/socket/src/utils/emojiData.ts b/src/packages/socket/src/utils/emojiData.ts index 9a2f789a..56c4e32e 100644 --- a/src/packages/socket/src/utils/emojiData.ts +++ b/src/packages/socket/src/utils/emojiData.ts @@ -96,6 +96,16 @@ export function getCustomEmojis(serverHost?: string): EmojiEntry[] { return serverHost ? (customEmojisByServer.get(serverHost) ?? EMPTY_CUSTOM_EMOJIS) : customEmojisCache; } +/** Custom emoji from every other server this device has loaded, by host. */ +export function getOtherServersEmojis(serverHost: string | undefined): [string, EmojiEntry[]][] { + return [...customEmojisByServer].filter(([host, list]) => host !== serverHost && list.length > 0); +} + +/** How a message carries another server's emoji: the image itself, since the name means nothing here. */ +export function externalEmojiMarkdown(entry: EmojiEntry): string { + return `![:${entry.name}:](${entry.url})`; +} + export function getAllEmojis(serverHost?: string): EmojiEntry[] { return [...getStandardEmojis(), ...getCustomEmojis(serverHost)]; } diff --git a/src/packages/socket/src/utils/externalEmojiPolicy.ts b/src/packages/socket/src/utils/externalEmojiPolicy.ts new file mode 100644 index 00000000..2f46b3d7 --- /dev/null +++ b/src/packages/socket/src/utils/externalEmojiPolicy.ts @@ -0,0 +1,27 @@ +/* Which servers let other Gryt servers' emoji show in their messages (GRYT-1660). Kept apart from + the server details so a message re-renders only when this answer changes. */ + +import { useSyncExternalStore } from "react"; + +const allowed = new Map(); +const listeners = new Set<() => void>(); + +export function setExternalEmojisAllowed(host: string, on: boolean): void { + if (allowed.get(host) === on) return; + allowed.set(host, on); + listeners.forEach((fn) => fn()); +} + +export function externalEmojisAllowed(host: string | null | undefined): boolean { + return !!host && allowed.get(host) === true; +} + +export function useExternalEmojisAllowed(host: string | null | undefined): boolean { + return useSyncExternalStore( + (fn) => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + () => externalEmojisAllowed(host), + ); +} diff --git a/src/packages/socket/src/utils/remarkEmoji.ts b/src/packages/socket/src/utils/remarkEmoji.ts index 39b660f9..b5262e37 100644 --- a/src/packages/socket/src/utils/remarkEmoji.ts +++ b/src/packages/socket/src/utils/remarkEmoji.ts @@ -231,12 +231,16 @@ export function preprocessCustomEmojis( } if (map.size === 0) return content; - return content.replace(/:([a-zA-Z0-9_+-]+):/g, (match, code) => { - if (nameToEmoji[code]) return match; - const url = map.get(code); - if (url) return `![${match}](${url})`; - return match; - }); + // Image markdown is left whole: another server's emoji arrives as one, with its name as the alt. + return content + .split(/(!\[[^\]\n]*\]\([^)\s]*\))/g) + .map((part, i) => (i % 2 === 1 ? part : part.replace(/:([a-zA-Z0-9_+-]+):/g, (match, code) => { + if (nameToEmoji[code]) return match; + const url = map.get(code); + if (url) return `![${match}](${url})`; + return match; + }))) + .join(""); } function buildReplacements(value: string): PhrasingContent[] {