feat(expressions): custom emojis and stickers per space
Emojis and stickers get separate tables on purpose: an emoji sits inside a sentence and a sticker is the whole message, so their sizes, limits and render paths differ. Emoji names are unique per space and restricted to letters, digits and underscore — :name: has to resolve to one image, and a space or colon inside the name would make the reference impossible to delimit. Sticker names are labels picked from a grid, so they accept anything. Rendering reuses the existing markdown pipeline by rewriting :name: into image syntax, which needs no plugin. Code spans and fences are left alone: text someone wrapped in backticks was meant to be shown literally, and turning it into an image would destroy what they wrote. Messages carry stickerId with ON DELETE set null — removing a sticker from the space must not delete the history of everyone who used it. The server refuses a sticker from another space, which would otherwise leak images between unrelated servers. Expressions load once per space and stay in memory: the message renderer consults the map for every :name: it finds, and a request per lookup would turn each message into a cascade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { isCustomEmojiAlt } from '../../utils/customEmoji';
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Highlight, themes } from 'prism-react-renderer';
|
||||
@@ -186,7 +187,22 @@ function buildComponents(): Components {
|
||||
hr: () => <hr className="border-border-soft my-2" />,
|
||||
|
||||
// Images (in markdown content — not attachments)
|
||||
img: ({ src, alt }) => (
|
||||
img: ({ src, alt }) => {
|
||||
// Emoji próprio do espaço chega como imagem markdown com alt `:nome:`.
|
||||
// Renderiza em tamanho de texto e inline, para caber no meio da frase em
|
||||
// vez de virar um bloco como uma imagem comum.
|
||||
if (isCustomEmojiAlt(alt ?? undefined)) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ''}
|
||||
title={alt ?? ''}
|
||||
className="inline-block align-text-bottom w-[22px] h-[22px] object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 max-w-[400px]">
|
||||
<img
|
||||
src={src}
|
||||
@@ -197,7 +213,8 @@ function buildComponents(): Components {
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// Tables (GFM)
|
||||
table: ({ children }) => (
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useExpressionStore } from '../../stores/expressionStore';
|
||||
import { renderCustomEmojis } from '../../utils/customEmoji';
|
||||
import { api } from '../../api/client';
|
||||
import { useT } from '../../i18n';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -233,6 +235,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
return () => clearTimeout(confirmDeleteTimeout.current);
|
||||
}, []);
|
||||
|
||||
// Emojis próprios do espaço são resolvidos antes do markdown; sem espaço
|
||||
// (DM, por exemplo) o texto passa intacto.
|
||||
const emojiSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const emojiLookup = useExpressionStore((s) => s.emojiByName);
|
||||
const contentWithEmojis = useMemo(() => {
|
||||
if (!message.content || !emojiSpaceId) return message.content ?? '';
|
||||
return renderCustomEmojis(
|
||||
message.content,
|
||||
(name) => emojiLookup(emojiSpaceId, name),
|
||||
(filename) => api.uploads.url(filename),
|
||||
);
|
||||
}, [message.content, emojiSpaceId, emojiLookup]);
|
||||
|
||||
const stickerId = (message as MessageWithUser).stickerId;
|
||||
const sticker = useExpressionStore((s) =>
|
||||
stickerId && emojiSpaceId
|
||||
? s.stickersBySpace.get(emojiSpaceId)?.find((k) => k.id === stickerId)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const isGifOnly = isGifOnlyMessage(message.content);
|
||||
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
|
||||
// sourceUrl: the original URL for context menu Copy/Open Link actions
|
||||
@@ -528,7 +550,19 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
||||
<>
|
||||
{message.content && (
|
||||
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
||||
<MarkdownRenderer content={message.content} />
|
||||
{sticker ? (
|
||||
// Figurinha ocupa a mensagem inteira, sem moldura de
|
||||
// anexo: é o conteúdo, não um arquivo acompanhando texto.
|
||||
<img
|
||||
src={api.uploads.url(sticker.filename)}
|
||||
alt={sticker.name}
|
||||
title={sticker.name}
|
||||
className="w-[160px] h-[160px] object-contain rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<MarkdownRenderer content={contentWithEmojis} />
|
||||
)}
|
||||
{message.editedAt && (
|
||||
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user