Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/packages/settings/src/types/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/packages/socket/src/components/ChatEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -497,7 +498,18 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
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) {
Expand Down Expand Up @@ -721,6 +733,7 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
onSelect={handlePickerEmojiSelect}
onClose={() => setEmojiPickerOpen(false)}
serverHost={serverHost}
withOtherServers
/>
)}
</div>
Expand Down
25 changes: 19 additions & 6 deletions src/packages/socket/src/components/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand All @@ -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]);
Expand All @@ -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 (
<GrytProvider theme={theme} className="min-h-0 w-full">
<GrytEmojiPicker
Expand All @@ -58,13 +69,15 @@ export function EmojiPickerContent({ onSelect, serverHost, autoFocusSearch = tru
searchPlaceholder="Search emojis..."
className="max-w-none"
onKeyDown={(event) => { 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 ?? "",
)}
/>
</GrytProvider>
);
}

export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above", serverHost }: EmojiPickerProps) => {
export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above", serverHost, withOtherServers }: EmojiPickerProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [fixedPos, setFixedPos] = useState<{ top?: number; bottom?: number; left: number }>({ left: 0 });
useLayoutEffect(() => {
Expand Down Expand Up @@ -131,7 +144,7 @@ export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above",
<div ref={containerRef} style={{ position: "fixed", ...fixedPos, width: PICKER_WIDTH, maxWidth: "calc(100vw - 16px)",
maxHeight: PICKER_MAX_HEIGHT, display: "flex", flexDirection: "column", zIndex: "var(--gryt-z-popover)" }}
onMouseDown={(event) => event.stopPropagation()}>
<EmojiPickerContent onSelect={select} serverHost={serverHost} />
<EmojiPickerContent onSelect={select} serverHost={serverHost} withOtherServers={withOtherServers} />
</div>
);
};
11 changes: 7 additions & 4 deletions src/packages/socket/src/components/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -375,7 +376,7 @@ export const MarkdownRenderer = memo(({
memberNicknames,
mentionMembersById,
serverHost,
allowExternalEmojis = false,
allowExternalEmojis,
profanityMatches,
blurProfanity,
smileyConversion = true,
Expand All @@ -386,14 +387,16 @@ export const MarkdownRenderer = memo(({
memberNicknames?: string[];
mentionMembersById?: Record<string, { nickname: string }>;
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;
smileyConversion?: boolean;
disabledSmileys?: ReadonlySet<string>;
}) => {
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);

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;

Expand Down
27 changes: 27 additions & 0 deletions src/packages/socket/src/components/ServerOverviewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down Expand Up @@ -106,6 +108,7 @@ export function ServerOverviewTab({
const [spamFilter, setSpamFilter] = useState(true);
const [spamSensitivity, setSpamSensitivity] = useState<SpamSensitivity>("normal");
const [videoProfiles, setVideoProfiles] = useState(true);
const [externalEmojis, setExternalEmojis] = useState<boolean | null>(null);
const [media, setMedia] = useState<{ checked: boolean; video: boolean } | null>(null);

const [autosaving, setAutosaving] = useState(false);
Expand All @@ -125,6 +128,7 @@ export function ServerOverviewTab({
spamFilter: boolean;
spamSensitivity: SpamSensitivity;
videoProfiles: boolean;
externalEmojis: boolean;
} | null>(null);

const [avatarMaxMb, setAvatarMaxMb] = useState<string>("");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -213,6 +218,7 @@ export function ServerOverviewTab({
spamFilter: payload.spamFilter !== false,
spamSensitivity: normalizeSpamSensitivity(payload.spamSensitivity),
videoProfiles: payload.videoProfiles !== false,
externalEmojis: payload.externalEmojis === true,
};
};

Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -594,6 +601,26 @@ export function ServerOverviewTab({
</div>
)}

{externalEmojis !== null && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Emoji from other servers</span>
<div className="flex items-center gap-2">
<Switch
checked={externalEmojis}
onCheckedChange={(v) => {
setExternalEmojis(v);
if (!saveIfChanged({ externalEmojis: v })) setExternalEmojis(!v);
}}
disabled={!canEdit}
/>
<span className="text-sm">Let members use custom emoji from their other servers</span>
</div>
<span className="text-xs text-gryt-muted" style={{ lineHeight: 1.4 }}>
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.
</span>
</div>
)}

<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Limits (optional)
Expand Down
3 changes: 3 additions & 0 deletions src/packages/socket/src/hooks/registerServerSocketEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down
10 changes: 10 additions & 0 deletions src/packages/socket/src/utils/emojiData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
}
Expand Down
27 changes: 27 additions & 0 deletions src/packages/socket/src/utils/externalEmojiPolicy.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>();
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),
);
}
16 changes: 10 additions & 6 deletions src/packages/socket/src/utils/remarkEmoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
Loading