feat(pins): pin messages to a channel

Pinned state lives on the message rather than a join table: a message is
pinned in exactly one channel, its own, so a separate table would add a join to
every lookup and buy nothing.

Every message now carries its pin state, so the timeline can mark a pin without
a second request and the panel and the timeline cannot disagree. Toggling is
deliberately not optimistic — the server refuses past the channel's limit, and
showing it pinned before confirmation would lie in exactly that case.

The pins list reuses the same assembly the channel history uses, extracted into
one helper, so the two cannot drift apart in what they include.

The migration also adds the (channel, user, created) index the filtered search
will need, since both touch the same table and one migration is cheaper than
two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 17:45:33 -03:00
co-authored by Claude Opus 5
parent c4158ee245
commit 4ceb5cd66a
14 changed files with 4669 additions and 1 deletions
@@ -0,0 +1,4 @@
ALTER TABLE `messages` ADD `pinned_at` integer;--> statement-breakpoint
ALTER TABLE `messages` ADD `pinned_by` text;--> statement-breakpoint
CREATE INDEX `idx_messages_channel_pinned` ON `messages` (`channel_id`,`pinned_at`);--> statement-breakpoint
CREATE INDEX `idx_messages_channel_user_created` ON `messages` (`channel_id`,`user_id`,`created_at`);
File diff suppressed because it is too large Load Diff
@@ -113,6 +113,13 @@
"when": 1788194631751,
"tag": "0015_young_human_fly",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1788295165743,
"tag": "0016_lyrical_freak",
"breakpoints": true
}
]
}
+9
View File
@@ -81,6 +81,11 @@ export const messages = sqliteTable('messages', {
replyToId: text('reply_to_id'),
content: text('content'),
editedAt: integer('edited_at'),
// Pinned state lives on the message rather than in a join table: a message is
// pinned in exactly one channel — its own — so a separate table would only
// add a join to every pin lookup.
pinnedAt: integer('pinned_at'),
pinnedBy: text('pinned_by'),
createdAt: integer('created_at').notNull(),
}, (table) => ({
replyToFk: foreignKey({
@@ -89,6 +94,10 @@ export const messages = sqliteTable('messages', {
}).onDelete('set null'),
channelIdx: index('idx_messages_channel_id').on(table.channelId),
userIdx: index('idx_messages_user_id').on(table.userId),
// Listing a channel's pins, newest first, without scanning its history.
pinnedIdx: index('idx_messages_channel_pinned').on(table.channelId, table.pinnedAt),
// Filtered search: author within a channel, ordered by recency.
searchIdx: index('idx_messages_channel_user_created').on(table.channelId, table.userId, table.createdAt),
}));
export const attachments = sqliteTable('attachments', {
+121 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
import { eq, and, desc, lt, inArray, isNotNull } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
@@ -145,6 +145,10 @@ export function buildMessageWithUser(
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
// Carried on every message so the client can show the pin marker without a
// second request, and so the pins panel and the timeline agree.
pinnedAt: message.pinnedAt,
pinnedBy: message.pinnedBy,
user: sanitizeUser(user),
attachments: attachmentRows.map(a => ({
id: a.id,
@@ -500,4 +504,120 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true });
});
// ─── Pins ────────────────────────────────────────────────────────────────
/**
* Assembles rows into the shape clients expect. Same batching the channel
* listing uses — kept in one place so pins and history cannot drift apart in
* what they include.
*/
function assembleMessages(rows: (typeof schema.messages.$inferSelect)[]): MessageWithUser[] {
if (rows.length === 0) return [];
const db = getDb();
const userIds = [...new Set(rows.map(m => m.userId))];
const userMap = new Map(
db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all().map(u => [u.id, u]),
);
const messageIds = rows.map(m => m.id);
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of db.select().from(schema.attachments)
.where(inArray(schema.attachments.messageId, messageIds)).all()) {
const mid = att.messageId ?? '';
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
attachmentMap.get(mid)!.push(att);
}
const reactionsMap = fetchReactionsForMessages(messageIds);
const embedMap = fetchEmbedsForMessages(messageIds);
const replyToMap = fetchReplyToMessages(rows);
return rows.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [],
reactionsMap.get(m.id) ?? [], replyTo, embedMap.get(m.id) ?? []);
}).filter((m): m is MessageWithUser => m !== null);
}
/** Discord caps a channel at 50; the same ceiling keeps the panel usable. */
const MAX_PINS_PER_CHANNEL = 50;
app.get<{ Params: { id: string } }>('/api/channels/:id/pins', { preHandler: authenticate }, async (request, reply) => {
const { id } = request.params;
const spaceId = getChannelSpaceId(id);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL, id)) {
return reply.code(403).send({ error: 'Cannot view this channel', statusCode: 403 });
}
const db = getDb();
const rows = db.select()
.from(schema.messages)
.where(and(eq(schema.messages.channelId, id), isNotNull(schema.messages.pinnedAt)))
.orderBy(desc(schema.messages.pinnedAt))
.all();
return reply.code(200).send({ messages: assembleMessages(rows) });
});
app.put<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
}
if (message.pinnedAt) return reply.code(200).send({ success: true });
const count = db.select({ id: schema.messages.id })
.from(schema.messages)
.where(and(eq(schema.messages.channelId, message.channelId), isNotNull(schema.messages.pinnedAt)))
.all().length;
if (count >= MAX_PINS_PER_CHANNEL) {
return reply.code(409).send({ error: `At most ${MAX_PINS_PER_CHANNEL} pinned messages per channel`, statusCode: 409 });
}
db.update(schema.messages)
.set({ pinnedAt: Date.now(), pinnedBy: request.userId })
.where(eq(schema.messages.id, message.id)).run();
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_pinned',
channelId: message.channelId,
messageId: message.id,
pinned: true,
});
return reply.code(200).send({ success: true });
});
app.delete<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
}
db.update(schema.messages)
.set({ pinnedAt: null, pinnedBy: null })
.where(eq(schema.messages.id, message.id)).run();
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_pinned',
channelId: message.channelId,
messageId: message.id,
pinned: false,
});
return reply.code(200).send({ success: true });
});
}
+4
View File
@@ -212,6 +212,9 @@ export interface Message {
type?: 'user' | 'system';
editedAt: number | null;
createdAt: number;
/** Epoch millis when pinned; null when not pinned. */
pinnedAt?: number | null;
pinnedBy?: string | null;
}
export interface MessageWithUser extends Message {
@@ -441,6 +444,7 @@ export type ServerEvent =
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
| { type: 'message_pinned'; channelId: string; messageId: string; pinned: boolean }
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
| { type: 'member_left'; spaceId: string; userId: string }
| { type: 'dm_message_created'; message: DmMessageWithUser }
+12
View File
@@ -321,6 +321,12 @@ export class BackspaceApiClient {
space: (spaceId: string, days: number) => Promise<SpaceStats>;
};
readonly pins: {
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
pin: (messageId: string) => Promise<{ success: boolean }>;
unpin: (messageId: string) => Promise<{ success: boolean }>;
};
readonly audit: {
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
};
@@ -751,6 +757,12 @@ export class BackspaceApiClient {
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
};
this.pins = {
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
unpin: (messageId: string) => request<{ success: boolean }>('DELETE', `/messages/${messageId}/pin`),
};
this.audit = {
log: (spaceId: string, before?: string) => {
const params = new URLSearchParams();
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { api } from '../../api/client';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom';
import type { MessageWithUser, Embed, User } from '@backspace/shared';
@@ -331,6 +332,15 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
setEditContent(message.content ?? '');
setIsEditing(true);
},
isPinned: Boolean((message as MessageWithUser).pinnedAt),
labels: { pin: tr('pins.pin'), unpin: tr('pins.unpin') },
onTogglePin: () => {
const pinned = Boolean((message as MessageWithUser).pinnedAt);
// Sem atualização otimista: o servidor recusa acima do limite do canal,
// e mostrar como fixada antes da confirmação mentiria nesse caso.
void (pinned ? api.pins.unpin(message.id) : api.pins.pin(message.id))
.catch(() => { /* o servidor rejeitou; o estado permanece o que era */ });
},
onDelete: () => deleteMessage(message.id, channelKey),
onReaction: (emoji: string) => toggleReaction(emoji),
onOpenEmojiPicker: () => {
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from 'react';
import type { MessageWithUser } from '@backspace/shared';
import { api } from '../../api/client';
import { Avatar } from '../ui/Avatar';
import { useT } from '../../i18n';
interface PinsPopoverProps {
channelId: string;
onClose: () => void;
onJumpToMessage: (messageId: string) => void;
}
export function PinsPopover({ channelId, onClose, onJumpToMessage }: PinsPopoverProps) {
const t = useT();
const [messages, setMessages] = useState<MessageWithUser[]>([]);
const [loading, setLoading] = useState(true);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.pins.list(channelId)
.then(({ messages: list }) => { if (!cancelled) setMessages(list); })
.catch(() => { if (!cancelled) setMessages([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [channelId]);
useEffect(() => {
const onPointer = (e: MouseEvent | TouchEvent) => {
if (!panelRef.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
// touchstart junto de mousedown: o iOS Safari não sintetiza mousedown de
// toque de forma confiável, como os outros popovers deste projeto tratam.
document.addEventListener('mousedown', onPointer);
document.addEventListener('touchstart', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('touchstart', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [onClose]);
return (
<div
ref={panelRef}
className="absolute right-0 top-full mt-2 z-[300] w-[380px] max-h-[460px] glass rounded-xl overflow-hidden flex flex-col shadow-xl"
>
<div className="px-4 py-3 border-b border-border-soft shrink-0">
<span className="text-[13px] font-semibold text-txt-primary">{t('pins.title')}</span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin p-2">
{loading ? (
<div className="space-y-2 p-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-14 rounded-lg bg-surface-elevated animate-pulse" />
))}
</div>
) : messages.length === 0 ? (
<p className="text-[12px] text-txt-tertiary p-3">{t('pins.empty')}</p>
) : (
<ul className="space-y-1">
{messages.map((m) => (
<li key={m.id}>
<button
onClick={() => { onJumpToMessage(m.id); onClose(); }}
className="w-full text-left flex gap-2.5 p-2 rounded-lg hover:bg-interactive-hover transition-colors"
>
<Avatar
src={m.user.avatar}
name={m.user.displayName ?? m.user.username}
size={28}
userId={m.user.id}
/>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-txt-primary truncate">
{m.user.displayName ?? m.user.username}
</div>
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">
{m.content || ''}
</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -16,6 +16,10 @@ interface MessageMenuParams {
canAddReactions: boolean;
canSendMessages: boolean;
canManageMessages: boolean;
isPinned: boolean;
onTogglePin: () => void;
/** Rótulos traduzidos: este módulo não é um componente e não pode usar o hook. */
labels: { pin: string; unpin: string };
onReply: () => void;
onEdit: () => void;
onDelete: () => void;
@@ -42,6 +46,9 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
canAddReactions,
canSendMessages,
canManageMessages,
isPinned,
onTogglePin,
labels,
onReply,
onEdit,
onDelete,
@@ -306,6 +313,21 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
});
}
// ── Pin / Unpin (moderator) ─────────────────────────────────────────────
if (canManageMessages) {
items.push({
key: 'pin',
type: 'action',
label: isPinned ? labels.unpin : labels.pin,
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
</svg>
),
onClick: onTogglePin,
});
}
// ── Delete Message (author or moderator) ────────────────────────────────
const canDelete = isAuthor || canManageMessages;
if (canDelete) {
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useT } from '../../i18n';
import { useLocation } from 'react-router-dom';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
@@ -25,6 +26,7 @@ import type { User } from '@backspace/shared';
import { Tooltip } from '../ui/Tooltip';
import { joinVoiceChannel } from '../../utils/voice';
import { SearchPopover } from '../chat/SearchPopover';
import { PinsPopover } from '../chat/PinsPopover';
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
export function MainContent() {
@@ -50,7 +52,9 @@ export function MainContent() {
const voiceContainerRef = useRef<HTMLDivElement>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null);
const t = useT();
const [searchOpen, setSearchOpen] = useState(false);
const [pinsOpen, setPinsOpen] = useState(false);
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
// Resolve the DM header's "first other" member through the canonical view
@@ -505,6 +509,24 @@ export function MainContent() {
</button>
<TransferIndicator />
<div className="w-[1px] h-5 bg-border-soft mx-1" />
<div className="relative">
<button
onClick={() => setPinsOpen((v) => !v)}
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[6px] ${pinsOpen ? 'text-txt-primary bg-interactive-active' : 'text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover'}`}
title={t('pins.title')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
</svg>
</button>
{pinsOpen && currentChannelId && (
<PinsPopover
channelId={currentChannelId}
onClose={() => setPinsOpen(false)}
onJumpToMessage={(id) => setJumpToMessageId(id)}
/>
)}
</div>
<MemberListToggleButton />
</div>
</div>
+17
View File
@@ -519,6 +519,23 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
break;
case 'message_pinned': {
// Atualiza a mensagem já carregada em vez de recarregar o canal: fixar é
// uma mudança de um campo, e recarregar jogaria fora a posição de leitura.
// updateMessage chaveia por message.channelId, então basta achar a
// mensagem na lista daquele canal.
const cs = useChatStore.getState();
const list = cs.messages.get(event.channelId);
const found = list?.find((m) => m.id === event.messageId);
if (found) {
cs.updateMessage({
...found,
pinnedAt: event.pinned ? Date.now() : null,
});
}
break;
}
case 'message_updated':
if (!isHome) {
normalizeMessageAssets(event.message, origin);
+7
View File
@@ -56,6 +56,13 @@ export const en = {
'accountMenu.copyId': 'Copy User ID',
'accountMenu.copied': 'Copied',
// Pinned messages
'pins.pin': 'Pin Message',
'pins.unpin': 'Unpin Message',
'pins.title': 'Pinned Messages',
'pins.empty': 'No pinned messages in this channel yet.',
'pins.limit': 'This channel has reached the pin limit.',
// Sidebar — spaces, channels and members
'sidebar.friends': 'Friends',
'sidebar.directMessages': 'Direct Messages',
+7
View File
@@ -55,6 +55,13 @@ export const ptBR: Partial<Dictionary> = {
'accountMenu.copyId': 'Copiar ID do usuário',
'accountMenu.copied': 'Copiado',
// Mensagens fixadas
'pins.pin': 'Fixar mensagem',
'pins.unpin': 'Desafixar mensagem',
'pins.title': 'Mensagens fixadas',
'pins.empty': 'Nenhuma mensagem fixada neste canal ainda.',
'pins.limit': 'Este canal atingiu o limite de mensagens fixadas.',
// Barra lateral — servidores, canais e membros
'sidebar.friends': 'Amigos',
'sidebar.directMessages': 'Mensagens diretas',