feat: implement message search and clean up header buttons
Remove placeholder buttons (Threads, Inbox, Help) from channel and DM headers. Add full-text message search with backend endpoints for both space channels and DMs, supporting filters (from, has, before, after) and pagination. Search popover with debounced input, highlighted matches, and jump-to-message that scrolls with a highlight animation. Includes messages/around endpoints for loading context when jumping to uncached messages.
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and, desc, lt, gt, like, inArray, sql, asc } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { hasPermission, getChannelSpaceId, PermissionBits, isDmMember } from '../utils/permissions.js';
|
||||
import { fetchReactionsForMessages, fetchReplyToMessages, buildMessageWithUser } from './messages.js';
|
||||
import { fetchDmReactionsForMessages, buildDmMessageWithUser } from './dm.js';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import type { MessageWithUser, DmMessageWithUser } from '@backspace/shared';
|
||||
|
||||
interface SearchQuery {
|
||||
q?: string;
|
||||
from?: string;
|
||||
has?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
offset?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
interface AroundQuery {
|
||||
messageId: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
export async function searchRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/channels/:id/search — Search messages in a space channel
|
||||
app.get<{ Params: { id: string }; Querystring: SearchQuery }>('/api/channels/:id/search', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { q, from, has, before, after } = request.query;
|
||||
const offset = Math.max(Number(request.query.offset) || 0, 0);
|
||||
const limit = Math.min(Math.max(Number(request.query.limit) || 25, 1), 50);
|
||||
|
||||
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 | PermissionBits.READ_MESSAGE_HISTORY, id)) {
|
||||
return reply.code(403).send({ error: 'Missing permissions', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(schema.messages.channelId, id)];
|
||||
|
||||
if (q && q.trim()) {
|
||||
conditions.push(like(schema.messages.content, `%${q.trim()}%`));
|
||||
}
|
||||
|
||||
if (from && from.trim()) {
|
||||
const user = db.select().from(schema.users)
|
||||
.where(like(schema.users.username, from.trim()))
|
||||
.get();
|
||||
if (user) {
|
||||
conditions.push(eq(schema.messages.userId, user.id));
|
||||
} else {
|
||||
return reply.code(200).send({ results: [], totalCount: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
if (before) {
|
||||
const ts = new Date(before).getTime();
|
||||
if (!isNaN(ts)) {
|
||||
conditions.push(lt(schema.messages.createdAt, ts));
|
||||
}
|
||||
}
|
||||
|
||||
if (after) {
|
||||
const ts = new Date(after).getTime();
|
||||
if (!isNaN(ts)) {
|
||||
conditions.push(gt(schema.messages.createdAt, ts));
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions)!;
|
||||
|
||||
// Handle has: filter with subqueries
|
||||
let hasFilter: ReturnType<typeof sql> | null = null;
|
||||
if (has === 'file' || has === 'image') {
|
||||
hasFilter = sql`EXISTS (SELECT 1 FROM attachments WHERE attachments.message_id = messages.id${
|
||||
has === 'image' ? sql` AND attachments.mimetype LIKE 'image/%'` : sql``
|
||||
})`;
|
||||
} else if (has === 'link') {
|
||||
conditions.push(like(schema.messages.content, '%http%'));
|
||||
}
|
||||
|
||||
// Count total
|
||||
let countQuery;
|
||||
if (hasFilter) {
|
||||
countQuery = db.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.messages)
|
||||
.where(and(whereClause, hasFilter))
|
||||
.get();
|
||||
} else {
|
||||
countQuery = db.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.messages)
|
||||
.where(whereClause)
|
||||
.get();
|
||||
}
|
||||
const totalCount = countQuery?.count ?? 0;
|
||||
|
||||
// Fetch results
|
||||
let messageRows: (typeof schema.messages.$inferSelect)[];
|
||||
if (hasFilter) {
|
||||
messageRows = db.select()
|
||||
.from(schema.messages)
|
||||
.where(and(whereClause, hasFilter))
|
||||
.orderBy(desc(schema.messages.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
} else {
|
||||
messageRows = db.select()
|
||||
.from(schema.messages)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(schema.messages.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
}
|
||||
|
||||
if (messageRows.length === 0) {
|
||||
return reply.code(200).send({ results: [], totalCount });
|
||||
}
|
||||
|
||||
// Hydrate results
|
||||
const userIds = [...new Set(messageRows.map(m => m.userId))];
|
||||
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const messageIds = messageRows.map(m => m.id);
|
||||
const allAttachments = db.select()
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.messageId, messageIds))
|
||||
.all();
|
||||
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
|
||||
for (const att of allAttachments) {
|
||||
const mid = att.messageId ?? '';
|
||||
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
|
||||
attachmentMap.get(mid)!.push(att);
|
||||
}
|
||||
|
||||
const reactionsMap = fetchReactionsForMessages(messageIds);
|
||||
const replyToMap = fetchReplyToMessages(messageRows);
|
||||
|
||||
const results: MessageWithUser[] = messageRows
|
||||
.map(m => {
|
||||
const user = userMap.get(m.userId);
|
||||
if (!user) return null;
|
||||
const reactions = reactionsMap.get(m.id) ?? [];
|
||||
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
|
||||
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo);
|
||||
})
|
||||
.filter((m): m is MessageWithUser => m !== null);
|
||||
|
||||
return reply.code(200).send({ results, totalCount });
|
||||
});
|
||||
|
||||
// GET /api/dm/:id/search — Search messages in a DM channel
|
||||
app.get<{ Params: { id: string }; Querystring: SearchQuery }>('/api/dm/:id/search', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { q, from, has, before, after } = request.query;
|
||||
const offset = Math.max(Number(request.query.offset) || 0, 0);
|
||||
const limit = Math.min(Math.max(Number(request.query.limit) || 25, 1), 50);
|
||||
|
||||
if (!isDmMember(id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(schema.dmMessages.dmChannelId, id)];
|
||||
|
||||
if (q && q.trim()) {
|
||||
conditions.push(like(schema.dmMessages.content, `%${q.trim()}%`));
|
||||
}
|
||||
|
||||
if (from && from.trim()) {
|
||||
const user = db.select().from(schema.users)
|
||||
.where(like(schema.users.username, from.trim()))
|
||||
.get();
|
||||
if (user) {
|
||||
conditions.push(eq(schema.dmMessages.userId, user.id));
|
||||
} else {
|
||||
return reply.code(200).send({ results: [], totalCount: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
if (before) {
|
||||
const ts = new Date(before).getTime();
|
||||
if (!isNaN(ts)) {
|
||||
conditions.push(lt(schema.dmMessages.createdAt, ts));
|
||||
}
|
||||
}
|
||||
|
||||
if (after) {
|
||||
const ts = new Date(after).getTime();
|
||||
if (!isNaN(ts)) {
|
||||
conditions.push(gt(schema.dmMessages.createdAt, ts));
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions)!;
|
||||
|
||||
let hasFilter: ReturnType<typeof sql> | null = null;
|
||||
if (has === 'file' || has === 'image') {
|
||||
hasFilter = sql`EXISTS (SELECT 1 FROM attachments WHERE attachments.dm_message_id = dm_messages.id${
|
||||
has === 'image' ? sql` AND attachments.mimetype LIKE 'image/%'` : sql``
|
||||
})`;
|
||||
} else if (has === 'link') {
|
||||
conditions.push(like(schema.dmMessages.content, '%http%'));
|
||||
}
|
||||
|
||||
let countQuery;
|
||||
if (hasFilter) {
|
||||
countQuery = db.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.dmMessages)
|
||||
.where(and(whereClause, hasFilter))
|
||||
.get();
|
||||
} else {
|
||||
countQuery = db.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.dmMessages)
|
||||
.where(whereClause)
|
||||
.get();
|
||||
}
|
||||
const totalCount = countQuery?.count ?? 0;
|
||||
|
||||
let messageRows: (typeof schema.dmMessages.$inferSelect)[];
|
||||
if (hasFilter) {
|
||||
messageRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(and(whereClause, hasFilter))
|
||||
.orderBy(desc(schema.dmMessages.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
} else {
|
||||
messageRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(schema.dmMessages.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
}
|
||||
|
||||
if (messageRows.length === 0) {
|
||||
return reply.code(200).send({ results: [], totalCount });
|
||||
}
|
||||
|
||||
// Hydrate
|
||||
const userIds = [...new Set(messageRows.map(m => m.userId))];
|
||||
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const messageIds = messageRows.map(m => m.id);
|
||||
const allAttachments = db.select()
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.dmMessageId, messageIds))
|
||||
.all();
|
||||
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
|
||||
for (const att of allAttachments) {
|
||||
const mid = att.dmMessageId ?? '';
|
||||
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
|
||||
attachmentMap.get(mid)!.push(att);
|
||||
}
|
||||
|
||||
const reactionsMap = fetchDmReactionsForMessages(messageIds);
|
||||
|
||||
// Fetch reply-to messages for DMs
|
||||
const replyToIds = messageRows
|
||||
.map(m => m.replyToId)
|
||||
.filter((rid): rid is string => rid !== null && rid !== undefined);
|
||||
const uniqueReplyIds = [...new Set(replyToIds)];
|
||||
const replyToMap = new Map<string, DmMessageWithUser>();
|
||||
if (uniqueReplyIds.length > 0) {
|
||||
const replyMessages = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(inArray(schema.dmMessages.id, uniqueReplyIds))
|
||||
.all();
|
||||
const replyUserIds = [...new Set(replyMessages.map(m => m.userId))];
|
||||
const replyUsers = replyUserIds.length > 0
|
||||
? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all()
|
||||
: [];
|
||||
const replyUserMap = new Map(replyUsers.map(u => [u.id, u]));
|
||||
for (const rm of replyMessages) {
|
||||
const rUser = replyUserMap.get(rm.userId);
|
||||
if (!rUser) continue;
|
||||
replyToMap.set(rm.id, {
|
||||
id: rm.id,
|
||||
dmChannelId: rm.dmChannelId,
|
||||
userId: rm.userId,
|
||||
replyToId: rm.replyToId,
|
||||
content: rm.content,
|
||||
editedAt: rm.editedAt,
|
||||
createdAt: rm.createdAt,
|
||||
user: sanitizeUser(rUser),
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const results: DmMessageWithUser[] = messageRows
|
||||
.map(m => {
|
||||
const user = userMap.get(m.userId);
|
||||
if (!user) return null;
|
||||
const reactions = reactionsMap.get(m.id) ?? [];
|
||||
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
|
||||
return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo);
|
||||
})
|
||||
.filter((m): m is DmMessageWithUser => m !== null);
|
||||
|
||||
return reply.code(200).send({ results, totalCount });
|
||||
});
|
||||
|
||||
// GET /api/channels/:id/messages/around — Load messages around a target message
|
||||
app.get<{ Params: { id: string }; Querystring: AroundQuery }>('/api/channels/:id/messages/around', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { messageId } = request.query;
|
||||
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
|
||||
const half = Math.floor(limit / 2);
|
||||
|
||||
if (!messageId) {
|
||||
return reply.code(400).send({ error: 'messageId is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
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 | PermissionBits.READ_MESSAGE_HISTORY, id)) {
|
||||
return reply.code(403).send({ error: 'Missing permissions', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Get the target message to know its timestamp
|
||||
const target = db.select().from(schema.messages)
|
||||
.where(and(eq(schema.messages.id, messageId), eq(schema.messages.channelId, id)))
|
||||
.get();
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
// Messages before (inclusive of target)
|
||||
const beforeRows = db.select()
|
||||
.from(schema.messages)
|
||||
.where(and(
|
||||
eq(schema.messages.channelId, id),
|
||||
sql`${schema.messages.id} <= ${messageId}`,
|
||||
))
|
||||
.orderBy(desc(schema.messages.createdAt))
|
||||
.limit(half + 1)
|
||||
.all();
|
||||
|
||||
// Messages after
|
||||
const afterRows = db.select()
|
||||
.from(schema.messages)
|
||||
.where(and(
|
||||
eq(schema.messages.channelId, id),
|
||||
gt(schema.messages.id, messageId),
|
||||
))
|
||||
.orderBy(asc(schema.messages.createdAt))
|
||||
.limit(half)
|
||||
.all();
|
||||
|
||||
// Combine in chronological order
|
||||
beforeRows.reverse();
|
||||
const messageRows = [...beforeRows, ...afterRows];
|
||||
|
||||
// Deduplicate (target message appears in both queries)
|
||||
const seen = new Set<string>();
|
||||
const uniqueRows = messageRows.filter(m => {
|
||||
if (seen.has(m.id)) return false;
|
||||
seen.add(m.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (uniqueRows.length === 0) {
|
||||
return reply.code(200).send([]);
|
||||
}
|
||||
|
||||
// Hydrate
|
||||
const userIds = [...new Set(uniqueRows.map(m => m.userId))];
|
||||
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const msgIds = uniqueRows.map(m => m.id);
|
||||
const allAttachments = db.select()
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.messageId, msgIds))
|
||||
.all();
|
||||
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
|
||||
for (const att of allAttachments) {
|
||||
const mid = att.messageId ?? '';
|
||||
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
|
||||
attachmentMap.get(mid)!.push(att);
|
||||
}
|
||||
|
||||
const reactionsMap = fetchReactionsForMessages(msgIds);
|
||||
const replyToMap = fetchReplyToMessages(uniqueRows);
|
||||
|
||||
const messages: MessageWithUser[] = uniqueRows
|
||||
.map(m => {
|
||||
const user = userMap.get(m.userId);
|
||||
if (!user) return null;
|
||||
const reactions = reactionsMap.get(m.id) ?? [];
|
||||
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
|
||||
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo);
|
||||
})
|
||||
.filter((m): m is MessageWithUser => m !== null);
|
||||
|
||||
return reply.code(200).send(messages);
|
||||
});
|
||||
|
||||
// GET /api/dm/:id/messages/around — Load DM messages around a target message
|
||||
app.get<{ Params: { id: string }; Querystring: AroundQuery }>('/api/dm/:id/messages/around', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { messageId } = request.query;
|
||||
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
|
||||
const half = Math.floor(limit / 2);
|
||||
|
||||
if (!messageId) {
|
||||
return reply.code(400).send({ error: 'messageId is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (!isDmMember(id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const target = db.select().from(schema.dmMessages)
|
||||
.where(and(eq(schema.dmMessages.id, messageId), eq(schema.dmMessages.dmChannelId, id)))
|
||||
.get();
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
const beforeRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(and(
|
||||
eq(schema.dmMessages.dmChannelId, id),
|
||||
sql`${schema.dmMessages.id} <= ${messageId}`,
|
||||
))
|
||||
.orderBy(desc(schema.dmMessages.createdAt))
|
||||
.limit(half + 1)
|
||||
.all();
|
||||
|
||||
const afterRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(and(
|
||||
eq(schema.dmMessages.dmChannelId, id),
|
||||
gt(schema.dmMessages.id, messageId),
|
||||
))
|
||||
.orderBy(asc(schema.dmMessages.createdAt))
|
||||
.limit(half)
|
||||
.all();
|
||||
|
||||
beforeRows.reverse();
|
||||
const messageRows = [...beforeRows, ...afterRows];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const uniqueRows = messageRows.filter(m => {
|
||||
if (seen.has(m.id)) return false;
|
||||
seen.add(m.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (uniqueRows.length === 0) {
|
||||
return reply.code(200).send([]);
|
||||
}
|
||||
|
||||
// Hydrate
|
||||
const userIds = [...new Set(uniqueRows.map(m => m.userId))];
|
||||
const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all();
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const msgIds = uniqueRows.map(m => m.id);
|
||||
const allAttachments = db.select()
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||
.all();
|
||||
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
|
||||
for (const att of allAttachments) {
|
||||
const mid = att.dmMessageId ?? '';
|
||||
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
|
||||
attachmentMap.get(mid)!.push(att);
|
||||
}
|
||||
|
||||
const reactionsMap = fetchDmReactionsForMessages(msgIds);
|
||||
|
||||
const replyToIds = uniqueRows
|
||||
.map(m => m.replyToId)
|
||||
.filter((rid): rid is string => rid !== null && rid !== undefined);
|
||||
const uniqueReplyIds = [...new Set(replyToIds)];
|
||||
const replyToMap = new Map<string, DmMessageWithUser>();
|
||||
if (uniqueReplyIds.length > 0) {
|
||||
const replyMessages = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(inArray(schema.dmMessages.id, uniqueReplyIds))
|
||||
.all();
|
||||
const replyUserIds = [...new Set(replyMessages.map(m => m.userId))];
|
||||
const replyUsers = replyUserIds.length > 0
|
||||
? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all()
|
||||
: [];
|
||||
const replyUserMap = new Map(replyUsers.map(u => [u.id, u]));
|
||||
for (const rm of replyMessages) {
|
||||
const rUser = replyUserMap.get(rm.userId);
|
||||
if (!rUser) continue;
|
||||
replyToMap.set(rm.id, {
|
||||
id: rm.id,
|
||||
dmChannelId: rm.dmChannelId,
|
||||
userId: rm.userId,
|
||||
replyToId: rm.replyToId,
|
||||
content: rm.content,
|
||||
editedAt: rm.editedAt,
|
||||
createdAt: rm.createdAt,
|
||||
user: sanitizeUser(rUser),
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const messages: DmMessageWithUser[] = uniqueRows
|
||||
.map(m => {
|
||||
const user = userMap.get(m.userId);
|
||||
if (!user) return null;
|
||||
const reactions = reactionsMap.get(m.id) ?? [];
|
||||
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
|
||||
return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo);
|
||||
})
|
||||
.filter((m): m is DmMessageWithUser => m !== null);
|
||||
|
||||
return reply.code(200).send(messages);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user