diff --git a/docs/roadmap-resenha.md b/docs/roadmap-resenha.md index 7f13ac44..ca9bdcfe 100644 --- a/docs/roadmap-resenha.md +++ b/docs/roadmap-resenha.md @@ -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 | | Á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 | +| 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 | | 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 | |---|---|---| | 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) @@ -119,7 +121,6 @@ Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.** | 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 | | **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 | diff --git a/packages/web/src/components/chat/EmojiPicker.tsx b/packages/web/src/components/chat/EmojiPicker.tsx index c827b039..7e8670fc 100644 --- a/packages/web/src/components/chat/EmojiPicker.tsx +++ b/packages/web/src/components/chat/EmojiPicker.tsx @@ -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 data from '@emoji-mart/data'; @@ -16,6 +19,24 @@ interface EmojiPickerProps { export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) { const containerRef = useRef(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) useEffect(() => { const el = containerRef.current; @@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
; gifEnabled: boolean; onTabChange: (tab: InputPopoverTab) => void; + onStickerSelect: (stickerId: string) => void; + hasStickers?: boolean; } interface SharedTabProps { @@ -53,6 +57,7 @@ function DesktopPopover({ onClose, onEmojiSelect, onGifSelect, + onStickerSelect, anchorRef, gifEnabled, onTabChange, @@ -139,6 +144,7 @@ function DesktopPopover({
{activeTab === 'emoji' && } {activeTab === 'gif' && gifEnabled && } + {activeTab === 'sticker' && }
, @@ -155,6 +161,7 @@ function MobileSheet({ onClose, onEmojiSelect, onGifSelect, + onStickerSelect, gifEnabled, onTabChange, availableTabs, @@ -221,6 +228,7 @@ function MobileSheet({
{activeTab === 'emoji' && } {activeTab === 'gif' && gifEnabled && } + {activeTab === 'sticker' && }
, @@ -231,12 +239,17 @@ function MobileSheet({ export function InputPopover(props: InputPopoverProps) { const isMobile = useUIStore((s) => s.isMobile); + const t = useT(); const availableTabs: { key: InputPopoverTab; label: string }[] = [ { key: 'emoji', label: 'Emoji' }, ]; if (props.gifEnabled) { 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) { return ; diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index 37dfd6e9..7ea20e68 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -96,6 +96,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp // Feature flags 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); // 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( - (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; if (!textarea) { - setDraft(channelId, draftText + emoji.native); + setDraft(channelId, draftText + inserted); return; } const start = textarea.selectionStart; const end = textarea.selectionEnd; const before = draftText.slice(0, start); const after = draftText.slice(end); - const newContent = before + emoji.native + after; + const newContent = before + inserted + after; setDraft(channelId, newContent); // Restore cursor position after the emoji - const newCursorPos = start + emoji.native.length; + const newCursorPos = start + inserted.length; requestAnimationFrame(() => { textarea.focus(); textarea.selectionStart = newCursorPos; @@ -768,6 +782,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp onClose={() => setActivePopover(null)} onEmojiSelect={handleEmojiSelect} onGifSelect={handleGifSelect} + onStickerSelect={handleStickerSelect} + hasStickers={Boolean(stickerSpaceId)} anchorRef={popoverAnchorRef} gifEnabled={gifEnabled} onTabChange={setActivePopover} diff --git a/packages/web/src/components/chat/StickerPicker.tsx b/packages/web/src/components/chat/StickerPicker.tsx new file mode 100644 index 00000000..f6298b81 --- /dev/null +++ b/packages/web/src/components/chat/StickerPicker.tsx @@ -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 ( +
+
+ + {t('expressions.pickerTitle')} + +
+ +
+ {stickers.length === 0 ? ( +

{t('expressions.pickerEmpty')}

+ ) : ( +
+ {stickers.map((sticker) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/packages/web/src/components/modals/SpaceSettings.tsx b/packages/web/src/components/modals/SpaceSettings.tsx index ff5764c0..c139b534 100644 --- a/packages/web/src/components/modals/SpaceSettings.tsx +++ b/packages/web/src/components/modals/SpaceSettings.tsx @@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel'; import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel'; import { StatsPanel } from './spaceSettingsPanels/StatsPanel'; +import { ExpressionsPanel } from './spaceSettingsPanels/ExpressionsPanel'; import { useT } from '../../i18n'; import { MembersPanel } from './spaceSettingsPanels/MembersPanel'; import { RolesPanel } from './spaceSettingsPanels/RolesPanel'; @@ -270,7 +271,7 @@ export function SpaceSettingsModal() { const spacePermissions = useSpaceStore((s) => s.spacePermissions); 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 isOpen = activeModal === 'spaceSettings'; @@ -339,6 +340,9 @@ export function SpaceSettingsModal() { )} + {canManageSpace && ( + + )} @@ -378,6 +382,9 @@ export function SpaceSettingsModal() { )} + {canManageSpace && ( + + )} )} @@ -406,6 +413,7 @@ export function SpaceSettingsModal() { {tab === 'bans' && canBanMembers && } {tab === 'audit' && canManageSpace && } {tab === 'stats' && } + {tab === 'expressions' && canManageSpace && } )} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/ExpressionsPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/ExpressionsPanel.tsx new file mode 100644 index 00000000..958092fe --- /dev/null +++ b/packages/web/src/components/modals/spaceSettingsPanels/ExpressionsPanel.tsx @@ -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('emoji'); + const [pending, setPending] = useState(null); + const [name, setName] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const fileRef = useRef(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 ( +
+

{t('expressions.title')}

+ +
+ {(['emoji', 'sticker'] as Kind[]).map((k) => ( + + ))} +
+ + + { const f = e.target.files?.[0]; if (f) pickFile(f); }} + /> + + {error &&

{error}

} + + {pending && ( +
+ + 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' && ( +

{t('expressions.nameHintEmoji')}

+ )} +
+ + +
+
+ )} + +
+ {items.length === 0 ? ( +

+ {kind === 'emoji' ? t('expressions.emptyEmojis') : t('expressions.emptyStickers')} +

+ ) : ( +
+ {items.map((item) => ( +
+
+ {item.name} +
+
+ {kind === 'emoji' ? `:${item.name}:` : item.name} +
+ +
+ ))} +
+ )} +
+
+ ); +} diff --git a/packages/web/src/i18n/locales/en.ts b/packages/web/src/i18n/locales/en.ts index e28c7037..e14917ba 100644 --- a/packages/web/src/i18n/locales/en.ts +++ b/packages/web/src/i18n/locales/en.ts @@ -56,6 +56,26 @@ export const en = { 'accountMenu.copyId': 'Copy User ID', '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.placeholder': 'Search messages…', 'search.filters': 'Filters', diff --git a/packages/web/src/i18n/locales/pt-BR.ts b/packages/web/src/i18n/locales/pt-BR.ts index 4b624653..9cfeab21 100644 --- a/packages/web/src/i18n/locales/pt-BR.ts +++ b/packages/web/src/i18n/locales/pt-BR.ts @@ -55,6 +55,26 @@ export const ptBR: Partial = { 'accountMenu.copyId': 'Copiar ID do usuário', '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 'search.placeholder': 'Buscar mensagens…', 'search.filters': 'Filtros', diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 79b246c7..721c71cc 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -58,7 +58,7 @@ interface ChatState { loadMessages: (channelId: string, force?: boolean) => Promise; clearAllMessages: () => void; loadMoreMessages: (channelId: string) => Promise; - sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise; + sendMessage: (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => Promise; editMessage: (messageId: string, content: string, channelId: string) => Promise; deleteMessage: (messageId: string, channelId: string) => Promise; addMessage: (channelId: string, message: MessageWithUser) => void; @@ -319,7 +319,7 @@ export const useChatStore = create((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 isDm = isDmChannel(channelId); const currentUser = useAuthStore.getState().user; @@ -367,7 +367,7 @@ export const useChatStore = create((set, get) => ({ if (isDm) { await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); } 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 } catch {