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[] {