feat(expressions): pickers and upload for emojis and stickers
Completes the feature: the tables existed but nothing could be put in them. Space settings gain an Emojis & Stickers panel behind MANAGE_SPACE, with a 512KB ceiling — both are fetched on every message that uses them, so weight matters more than fidelity. The suggested name is pre-normalised so the common case needs no typing, and a name collision reports itself distinctly from an upload failure: the corrective action is different. Custom emojis join the emoji picker as their own category. They have no native character, so selecting one inserts :name: — the same text the renderer resolves back to an image, which also means copying a message yields something that still reads. Stickers get a picker tab that only appears inside a space, since that is where they exist, and send immediately on click: a sticker is the whole message, so parking it in the composer to await Enter would make no sense. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,9 @@ código e os commits seguem em inglês, como o resto do repositório.
|
|||||||
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
|
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
|
||||||
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
|
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
|
||||||
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
|
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
|
||||||
|
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
|
||||||
|
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
|
||||||
|
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
|
||||||
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
@@ -60,7 +63,6 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
|||||||
| Feature | Tamanho | Observação técnica |
|
| Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||||
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
|
||||||
|
|
||||||
## Regra de idioma (a partir de 2026-08-31)
|
## Regra de idioma (a partir de 2026-08-31)
|
||||||
|
|
||||||
@@ -119,7 +121,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
|
|||||||
|
|
||||||
| Feature | Tamanho | Observação técnica |
|
| Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
|
|
||||||
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
||||||
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
|
import { api } from '../../api/client';
|
||||||
import Picker from '@emoji-mart/react';
|
import Picker from '@emoji-mart/react';
|
||||||
import data from '@emoji-mart/data';
|
import data from '@emoji-mart/data';
|
||||||
|
|
||||||
@@ -16,6 +19,24 @@ interface EmojiPickerProps {
|
|||||||
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
|
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Emojis próprios do servidor entram como categoria extra do emoji-mart.
|
||||||
|
// Sem espaço atual (DM) a lista fica vazia e a categoria não aparece.
|
||||||
|
const spaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const spaceEmojis = useExpressionStore((s) => (spaceId ? s.emojisBySpace.get(spaceId) : undefined));
|
||||||
|
const customCategories = useMemo(() => {
|
||||||
|
if (!spaceEmojis?.length) return [];
|
||||||
|
return [{
|
||||||
|
id: 'space',
|
||||||
|
name: 'Servidor',
|
||||||
|
emojis: spaceEmojis.map((e) => ({
|
||||||
|
id: e.name,
|
||||||
|
name: e.name,
|
||||||
|
keywords: [e.name],
|
||||||
|
skins: [{ src: api.uploads.url(e.filename) }],
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
}, [spaceEmojis]);
|
||||||
|
|
||||||
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
|
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
|
|||||||
<div ref={containerRef} className={wrapperClass}>
|
<div ref={containerRef} className={wrapperClass}>
|
||||||
<Picker
|
<Picker
|
||||||
data={data}
|
data={data}
|
||||||
|
custom={customCategories}
|
||||||
onEmojiSelect={onEmojiSelect}
|
onEmojiSelect={onEmojiSelect}
|
||||||
theme="dark"
|
theme="dark"
|
||||||
set="native"
|
set="native"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
import React, { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { EmojiPicker } from './EmojiPicker';
|
import { EmojiPicker } from './EmojiPicker';
|
||||||
import { GifPicker } from './GifPicker';
|
import { GifPicker } from './GifPicker';
|
||||||
|
import { StickerPicker } from './StickerPicker';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useDragToClose } from '../../hooks/useDragToClose';
|
import { useDragToClose } from '../../hooks/useDragToClose';
|
||||||
|
|
||||||
export type InputPopoverTab = 'emoji' | 'gif';
|
export type InputPopoverTab = 'emoji' | 'gif' | 'sticker';
|
||||||
|
|
||||||
interface InputPopoverProps {
|
interface InputPopoverProps {
|
||||||
activeTab: InputPopoverTab;
|
activeTab: InputPopoverTab;
|
||||||
@@ -15,6 +17,8 @@ interface InputPopoverProps {
|
|||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
gifEnabled: boolean;
|
gifEnabled: boolean;
|
||||||
onTabChange: (tab: InputPopoverTab) => void;
|
onTabChange: (tab: InputPopoverTab) => void;
|
||||||
|
onStickerSelect: (stickerId: string) => void;
|
||||||
|
hasStickers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SharedTabProps {
|
interface SharedTabProps {
|
||||||
@@ -53,6 +57,7 @@ function DesktopPopover({
|
|||||||
onClose,
|
onClose,
|
||||||
onEmojiSelect,
|
onEmojiSelect,
|
||||||
onGifSelect,
|
onGifSelect,
|
||||||
|
onStickerSelect,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
gifEnabled,
|
gifEnabled,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -139,6 +144,7 @@ function DesktopPopover({
|
|||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
||||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
||||||
|
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -155,6 +161,7 @@ function MobileSheet({
|
|||||||
onClose,
|
onClose,
|
||||||
onEmojiSelect,
|
onEmojiSelect,
|
||||||
onGifSelect,
|
onGifSelect,
|
||||||
|
onStickerSelect,
|
||||||
gifEnabled,
|
gifEnabled,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
availableTabs,
|
availableTabs,
|
||||||
@@ -221,6 +228,7 @@ function MobileSheet({
|
|||||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
||||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
||||||
|
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} mobile />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</>,
|
||||||
@@ -231,12 +239,17 @@ function MobileSheet({
|
|||||||
export function InputPopover(props: InputPopoverProps) {
|
export function InputPopover(props: InputPopoverProps) {
|
||||||
const isMobile = useUIStore((s) => s.isMobile);
|
const isMobile = useUIStore((s) => s.isMobile);
|
||||||
|
|
||||||
|
const t = useT();
|
||||||
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
||||||
{ key: 'emoji', label: 'Emoji' },
|
{ key: 'emoji', label: 'Emoji' },
|
||||||
];
|
];
|
||||||
if (props.gifEnabled) {
|
if (props.gifEnabled) {
|
||||||
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
||||||
}
|
}
|
||||||
|
// Figurinha só existe dentro de um servidor; em DM a aba não aparece.
|
||||||
|
if (props.hasStickers) {
|
||||||
|
availableTabs.push({ key: 'sticker', label: t('expressions.stickers') });
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
const tr = useT();
|
const tr = useT();
|
||||||
|
// Figurinhas pertencem a um servidor; em DM não há o que oferecer.
|
||||||
|
const stickerSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
|
||||||
|
const handleStickerSelect = (stickerId: string) => {
|
||||||
|
setActivePopover(null);
|
||||||
|
// Enviada de imediato: a figurinha é a mensagem inteira, então não faz
|
||||||
|
// sentido acumulá-la no campo de texto esperando um Enter.
|
||||||
|
void sendMessage(channelId, '', undefined, stickerId);
|
||||||
|
};
|
||||||
|
|
||||||
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
||||||
|
|
||||||
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||||
@@ -531,21 +541,25 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiSelect = useCallback(
|
const handleEmojiSelect = useCallback(
|
||||||
(emoji: { native: string }) => {
|
(emoji: { native?: string; id?: string }) => {
|
||||||
|
// Emoji próprio não tem `native`: entra no texto como `:nome:`, que é
|
||||||
|
// o que o render resolve depois para a imagem.
|
||||||
|
const inserted = emoji.native ?? (emoji.id ? `:${emoji.id}:` : '');
|
||||||
|
if (!inserted) return;
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) {
|
if (!textarea) {
|
||||||
setDraft(channelId, draftText + emoji.native);
|
setDraft(channelId, draftText + inserted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
const end = textarea.selectionEnd;
|
const end = textarea.selectionEnd;
|
||||||
const before = draftText.slice(0, start);
|
const before = draftText.slice(0, start);
|
||||||
const after = draftText.slice(end);
|
const after = draftText.slice(end);
|
||||||
const newContent = before + emoji.native + after;
|
const newContent = before + inserted + after;
|
||||||
setDraft(channelId, newContent);
|
setDraft(channelId, newContent);
|
||||||
|
|
||||||
// Restore cursor position after the emoji
|
// Restore cursor position after the emoji
|
||||||
const newCursorPos = start + emoji.native.length;
|
const newCursorPos = start + inserted.length;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.selectionStart = newCursorPos;
|
textarea.selectionStart = newCursorPos;
|
||||||
@@ -768,6 +782,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
onClose={() => setActivePopover(null)}
|
onClose={() => setActivePopover(null)}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
onGifSelect={handleGifSelect}
|
onGifSelect={handleGifSelect}
|
||||||
|
onStickerSelect={handleStickerSelect}
|
||||||
|
hasStickers={Boolean(stickerSpaceId)}
|
||||||
anchorRef={popoverAnchorRef}
|
anchorRef={popoverAnchorRef}
|
||||||
gifEnabled={gifEnabled}
|
gifEnabled={gifEnabled}
|
||||||
onTabChange={setActivePopover}
|
onTabChange={setActivePopover}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
|
interface StickerPickerProps {
|
||||||
|
onStickerSelect: (stickerId: string) => void;
|
||||||
|
mobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StickerPicker({ onStickerSelect, mobile = false }: StickerPickerProps) {
|
||||||
|
const t = useT();
|
||||||
|
const spaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const stickers = useExpressionStore((s) => (spaceId ? s.stickersBySpace.get(spaceId) : undefined)) ?? [];
|
||||||
|
const load = useExpressionStore((s) => s.load);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (spaceId) void load(spaceId);
|
||||||
|
}, [spaceId, load]);
|
||||||
|
|
||||||
|
// Mesmas dimensões do seletor de GIF, para as abas do popover não pularem de
|
||||||
|
// tamanho ao alternar.
|
||||||
|
const rootClass = mobile
|
||||||
|
? 'flex flex-col flex-1 min-h-0 w-full'
|
||||||
|
: 'flex flex-col h-[390px] w-[390px]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClass}>
|
||||||
|
<div className="px-3 pt-3 pb-2 shrink-0">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||||||
|
{t('expressions.pickerTitle')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
|
||||||
|
{stickers.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-txt-tertiary p-3">{t('expressions.pickerEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{stickers.map((sticker) => (
|
||||||
|
<button
|
||||||
|
key={sticker.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStickerSelect(sticker.id)}
|
||||||
|
title={sticker.name}
|
||||||
|
className="aspect-square rounded-lg bg-surface-elevated hover:brightness-125 transition-all flex items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(sticker.filename)}
|
||||||
|
alt={sticker.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
|||||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||||
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
|
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
|
||||||
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
|
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
|
||||||
|
import { ExpressionsPanel } from './spaceSettingsPanels/ExpressionsPanel';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||||
@@ -270,7 +271,7 @@ export function SpaceSettingsModal() {
|
|||||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats'>('overview');
|
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats' | 'expressions'>('overview');
|
||||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||||
|
|
||||||
const isOpen = activeModal === 'spaceSettings';
|
const isOpen = activeModal === 'spaceSettings';
|
||||||
@@ -339,6 +340,9 @@ export function SpaceSettingsModal() {
|
|||||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
||||||
|
{canManageSpace && (
|
||||||
|
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -378,6 +382,9 @@ export function SpaceSettingsModal() {
|
|||||||
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
|
||||||
|
{canManageSpace && (
|
||||||
|
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -406,6 +413,7 @@ export function SpaceSettingsModal() {
|
|||||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
|
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
|
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
|
||||||
|
{tab === 'expressions' && canManageSpace && <ExpressionsPanel spaceId={currentSpaceId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { api, type SpaceEmoji } from '../../../api/client';
|
||||||
|
import { useExpressionStore } from '../../../stores/expressionStore';
|
||||||
|
import { useTransferStore } from '../../../stores/transferStore';
|
||||||
|
import { waitForTransferAttachment } from '../../../utils/waitForTransfer';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
|
|
||||||
|
interface ExpressionsPanelProps {
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emoji e figurinha são carregados a cada mensagem: têm de ser leves. */
|
||||||
|
const MAX_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
type Kind = 'emoji' | 'sticker';
|
||||||
|
|
||||||
|
export function ExpressionsPanel({ spaceId }: ExpressionsPanelProps) {
|
||||||
|
const t = useT();
|
||||||
|
const emojis = useExpressionStore((s) => s.emojisBySpace.get(spaceId)) ?? [];
|
||||||
|
const stickers = useExpressionStore((s) => s.stickersBySpace.get(spaceId)) ?? [];
|
||||||
|
const setEmojis = useExpressionStore((s) => s.setEmojis);
|
||||||
|
const setStickers = useExpressionStore((s) => s.setStickers);
|
||||||
|
|
||||||
|
const [kind, setKind] = useState<Kind>('emoji');
|
||||||
|
const [pending, setPending] = useState<File | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([api.expressions.emojis(spaceId), api.expressions.stickers(spaceId)])
|
||||||
|
.then(([e, s]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEmojis(spaceId, e.emojis);
|
||||||
|
setStickers(spaceId, s.stickers);
|
||||||
|
})
|
||||||
|
.catch(() => { /* lista vazia é o fallback honesto */ });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [spaceId, setEmojis, setStickers]);
|
||||||
|
|
||||||
|
const pickFile = (file: File) => {
|
||||||
|
setError('');
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
setError(t('expressions.tooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(file);
|
||||||
|
// Sugere o nome do arquivo já no formato aceito, para o caso comum não
|
||||||
|
// exigir digitação nenhuma.
|
||||||
|
const base = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
setName(kind === 'emoji' ? base.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 32) : base.slice(0, 32));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
setPending(null);
|
||||||
|
setName('');
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
const file = pending;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!file || !trimmed) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||||
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
if (kind === 'emoji') {
|
||||||
|
const created = await api.expressions.addEmoji(spaceId, trimmed, filename);
|
||||||
|
setEmojis(spaceId, [...emojis, created]);
|
||||||
|
} else {
|
||||||
|
const created = await api.expressions.addSticker(spaceId, trimmed, filename);
|
||||||
|
setStickers(spaceId, [...stickers, created]);
|
||||||
|
}
|
||||||
|
cancel();
|
||||||
|
} catch (err) {
|
||||||
|
// 409 é nome repetido — mensagem específica, porque a ação corretiva é
|
||||||
|
// outra: mudar o nome, não trocar a imagem.
|
||||||
|
const conflict = (err as { statusCode?: number } | undefined)?.statusCode === 409;
|
||||||
|
setError(conflict ? t('expressions.nameTaken') : t('expressions.uploadFailed'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (item: SpaceEmoji) => {
|
||||||
|
const isEmoji = kind === 'emoji';
|
||||||
|
const previous = isEmoji ? emojis : stickers;
|
||||||
|
const next = previous.filter((x) => x.id !== item.id);
|
||||||
|
if (isEmoji) setEmojis(spaceId, next); else setStickers(spaceId, next);
|
||||||
|
try {
|
||||||
|
await (isEmoji ? api.expressions.removeEmoji(item.id) : api.expressions.removeSticker(item.id));
|
||||||
|
} catch {
|
||||||
|
if (isEmoji) setEmojis(spaceId, previous); else setStickers(spaceId, previous);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = kind === 'emoji' ? emojis : stickers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t('expressions.title')}</h2>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 mb-5">
|
||||||
|
{(['emoji', 'sticker'] as Kind[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setKind(k); cancel(); }}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-[12px] font-medium transition-colors ${
|
||||||
|
kind === k ? 'bg-accent-primary text-white' : 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{k === 'emoji' ? t('expressions.emojis') : t('expressions.stickers')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-3 py-1.5 rounded-md text-[13px] font-medium bg-accent-primary text-white hover:brightness-110 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{kind === 'emoji' ? t('expressions.addEmoji') : t('expressions.addSticker')}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickFile(f); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-[12px] text-txt-danger mt-2">{error}</p>}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-surface-elevated/60">
|
||||||
|
<label className="block text-[11px] text-txt-tertiary mb-1">{t('expressions.namePrompt')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
maxLength={32}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter' && name.trim()) void confirm();
|
||||||
|
if (e.key === 'Escape') cancel();
|
||||||
|
}}
|
||||||
|
className="input-search w-full mb-1"
|
||||||
|
/>
|
||||||
|
{kind === 'emoji' && (
|
||||||
|
<p className="text-[11px] text-txt-tertiary mb-2">{t('expressions.nameHintEmoji')}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void confirm()}
|
||||||
|
disabled={busy || !name.trim()}
|
||||||
|
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-accent-primary text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t('expressions.uploading') : t('expressions.confirm')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancel}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('expressions.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-txt-tertiary">
|
||||||
|
{kind === 'emoji' ? t('expressions.emptyEmojis') : t('expressions.emptyStickers')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="relative group">
|
||||||
|
<div className="aspect-square rounded-lg bg-surface-elevated flex items-center justify-center p-1.5">
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(item.filename)}
|
||||||
|
alt={item.name}
|
||||||
|
title={kind === 'emoji' ? `:${item.name}:` : item.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-txt-tertiary truncate text-center mt-0.5">
|
||||||
|
{kind === 'emoji' ? `:${item.name}:` : item.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void remove(item)}
|
||||||
|
title={t('expressions.remove')}
|
||||||
|
aria-label={t('expressions.remove')}
|
||||||
|
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-accent-rose text-white text-[10px] leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,26 @@ export const en = {
|
|||||||
'accountMenu.copyId': 'Copy User ID',
|
'accountMenu.copyId': 'Copy User ID',
|
||||||
'accountMenu.copied': 'Copied',
|
'accountMenu.copied': 'Copied',
|
||||||
|
|
||||||
|
// Custom emojis and stickers
|
||||||
|
'expressions.title': 'Emojis & Stickers',
|
||||||
|
'expressions.emojis': 'Emojis',
|
||||||
|
'expressions.stickers': 'Stickers',
|
||||||
|
'expressions.addEmoji': 'Add emoji',
|
||||||
|
'expressions.addSticker': 'Add sticker',
|
||||||
|
'expressions.emptyEmojis': 'No custom emojis yet.',
|
||||||
|
'expressions.emptyStickers': 'No stickers yet.',
|
||||||
|
'expressions.namePrompt': 'Name',
|
||||||
|
'expressions.nameHintEmoji': 'Letters, numbers and underscore. Used as :name: in messages.',
|
||||||
|
'expressions.confirm': 'Add',
|
||||||
|
'expressions.cancel': 'Cancel',
|
||||||
|
'expressions.remove': 'Remove',
|
||||||
|
'expressions.tooLarge': 'Image must be under 512 KB.',
|
||||||
|
'expressions.uploadFailed': 'Could not add that image. Try another one.',
|
||||||
|
'expressions.nameTaken': 'That name is already in use in this server.',
|
||||||
|
'expressions.uploading': 'Uploading…',
|
||||||
|
'expressions.pickerTitle': 'Stickers',
|
||||||
|
'expressions.pickerEmpty': 'This server has no stickers yet.',
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
'search.placeholder': 'Search messages…',
|
'search.placeholder': 'Search messages…',
|
||||||
'search.filters': 'Filters',
|
'search.filters': 'Filters',
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'accountMenu.copyId': 'Copiar ID do usuário',
|
'accountMenu.copyId': 'Copiar ID do usuário',
|
||||||
'accountMenu.copied': 'Copiado',
|
'accountMenu.copied': 'Copiado',
|
||||||
|
|
||||||
|
// Emojis e figurinhas
|
||||||
|
'expressions.title': 'Emojis e figurinhas',
|
||||||
|
'expressions.emojis': 'Emojis',
|
||||||
|
'expressions.stickers': 'Figurinhas',
|
||||||
|
'expressions.addEmoji': 'Adicionar emoji',
|
||||||
|
'expressions.addSticker': 'Adicionar figurinha',
|
||||||
|
'expressions.emptyEmojis': 'Nenhum emoji próprio ainda.',
|
||||||
|
'expressions.emptyStickers': 'Nenhuma figurinha ainda.',
|
||||||
|
'expressions.namePrompt': 'Nome',
|
||||||
|
'expressions.nameHintEmoji': 'Letras, números e sublinhado. Usado como :nome: nas mensagens.',
|
||||||
|
'expressions.confirm': 'Adicionar',
|
||||||
|
'expressions.cancel': 'Cancelar',
|
||||||
|
'expressions.remove': 'Remover',
|
||||||
|
'expressions.tooLarge': 'A imagem precisa ter menos de 512 KB.',
|
||||||
|
'expressions.uploadFailed': 'Não foi possível adicionar essa imagem. Tente outra.',
|
||||||
|
'expressions.nameTaken': 'Esse nome já está em uso neste servidor.',
|
||||||
|
'expressions.uploading': 'Enviando…',
|
||||||
|
'expressions.pickerTitle': 'Figurinhas',
|
||||||
|
'expressions.pickerEmpty': 'Este servidor ainda não tem figurinhas.',
|
||||||
|
|
||||||
// Busca
|
// Busca
|
||||||
'search.placeholder': 'Buscar mensagens…',
|
'search.placeholder': 'Buscar mensagens…',
|
||||||
'search.filters': 'Filtros',
|
'search.filters': 'Filtros',
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ interface ChatState {
|
|||||||
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
||||||
clearAllMessages: () => void;
|
clearAllMessages: () => void;
|
||||||
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
||||||
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
sendMessage: (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => Promise<void>;
|
||||||
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
||||||
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
||||||
addMessage: (channelId: string, message: MessageWithUser) => void;
|
addMessage: (channelId: string, message: MessageWithUser) => void;
|
||||||
@@ -319,7 +319,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
|
sendMessage: async (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => {
|
||||||
const replyToId = get().replyTo?.id;
|
const replyToId = get().replyTo?.id;
|
||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
const currentUser = useAuthStore.getState().user;
|
const currentUser = useAuthStore.getState().user;
|
||||||
@@ -367,7 +367,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (isDm) {
|
if (isDm) {
|
||||||
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||||
} else {
|
} else {
|
||||||
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId, stickerId });
|
||||||
}
|
}
|
||||||
// Real message will arrive via WebSocket and replace the temp one
|
// Real message will arrive via WebSocket and replace the temp one
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user