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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user