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:
Jannis Braun
2026-03-11 00:22:00 +01:00
parent f898690302
commit 5300e78d9d
10 changed files with 1037 additions and 33 deletions
+2
View File
@@ -20,6 +20,7 @@ import { settingsRoutes } from './routes/settings.js';
import { utilRoutes } from './routes/utils.js';
import { instanceRoutes } from './routes/instance.js';
import { exploreRoutes } from './routes/explore.js';
import { searchRoutes } from './routes/search.js';
import { registerWebSocket } from './ws/handler.js';
import path from 'path';
import fs from 'fs';
@@ -79,6 +80,7 @@ async function main(): Promise<void> {
await app.register(utilRoutes);
await app.register(instanceRoutes);
await app.register(exploreRoutes);
await app.register(searchRoutes);
await app.register(registerWebSocket);
app.get('/api/health', async () => {
+3 -3
View File
@@ -18,7 +18,7 @@ import { sanitizeUser } from '../utils/sanitize.js';
* Fetch reactions for a set of message IDs.
* Returns a map from messageId to Reaction[].
*/
function fetchReactionsForMessages(messageIds: string[]): Map<string, Reaction[]> {
export function fetchReactionsForMessages(messageIds: string[]): Map<string, Reaction[]> {
if (messageIds.length === 0) return new Map();
const db = getDb();
const reactionRows = db.select()
@@ -56,7 +56,7 @@ function fetchReactionsForMessages(messageIds: string[]): Map<string, Reaction[]
* Fetch reply-to messages for a set of message IDs.
* Returns a map from messageId to its reply parent MessageWithUser.
*/
function fetchReplyToMessages(messages: (typeof schema.messages.$inferSelect)[]): Map<string, MessageWithUser> {
export function fetchReplyToMessages(messages: (typeof schema.messages.$inferSelect)[]): Map<string, MessageWithUser> {
const replyToIds = messages
.map(m => m.replyToId)
.filter((id): id is string => id !== null && id !== undefined);
@@ -119,7 +119,7 @@ function fetchReplyToMessages(messages: (typeof schema.messages.$inferSelect)[])
return map;
}
function buildMessageWithUser(
export function buildMessageWithUser(
message: typeof schema.messages.$inferSelect,
user: typeof schema.users.$inferSelect,
attachmentRows: (typeof schema.attachments.$inferSelect)[],
+547
View File
@@ -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);
});
}
+42
View File
@@ -71,6 +71,7 @@ export class BackspaceApiClient {
update: (id: string, data: UpdateChannelRequest) => Promise<Channel>;
delete: (id: string) => Promise<{ success: boolean }>;
messages: (id: string, before?: string, limit?: number) => Promise<MessageWithUser[]>;
messagesAround: (id: string, messageId: string) => Promise<MessageWithUser[]>;
sendMessage: (channelId: string, data: CreateMessageRequest) => Promise<MessageWithUser>;
getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>;
putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>;
@@ -92,6 +93,7 @@ export class BackspaceApiClient {
create: (data: CreateDmRequest) => Promise<DmChannel>;
close: (id: string) => Promise<{ success: boolean }>;
messages: (id: string, before?: string, limit?: number) => Promise<DmMessageWithUser[]>;
messagesAround: (id: string, messageId: string) => Promise<DmMessageWithUser[]>;
sendMessage: (id: string, data: CreateDmMessageRequest) => Promise<DmMessageWithUser>;
updateMessage: (id: string, data: UpdateMessageRequest) => Promise<DmMessageWithUser>;
deleteMessage: (id: string) => Promise<{ success: boolean }>;
@@ -131,6 +133,11 @@ export class BackspaceApiClient {
delete: (spaceId: string, roleId: string) => Promise<{ success: boolean }>;
};
readonly search: {
channel: (channelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => Promise<{ results: MessageWithUser[]; totalCount: number }>;
dm: (dmChannelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => Promise<{ results: DmMessageWithUser[]; totalCount: number }>;
};
readonly explore: {
list: (q?: string, limit?: number, offset?: number) => Promise<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>;
publicJoin: (spaceId: string) => Promise<SpaceWithChannelsAndMembers>;
@@ -255,6 +262,11 @@ export class BackspaceApiClient {
params.set('limit', String(limit));
return request<MessageWithUser[]>('GET', `/channels/${id}/messages?${params}`);
},
messagesAround: (id: string, messageId: string) => {
const params = new URLSearchParams();
params.set('messageId', messageId);
return request<MessageWithUser[]>('GET', `/channels/${id}/messages/around?${params}`);
},
sendMessage: (channelId: string, data: CreateMessageRequest) =>
request<MessageWithUser>('POST', `/channels/${channelId}/messages`, data),
getOverrides: (channelId: string) =>
@@ -287,6 +299,11 @@ export class BackspaceApiClient {
params.set('limit', String(limit));
return request<DmMessageWithUser[]>('GET', `/dm/${id}/messages?${params}`);
},
messagesAround: (id: string, messageId: string) => {
const params = new URLSearchParams();
params.set('messageId', messageId);
return request<DmMessageWithUser[]>('GET', `/dm/${id}/messages/around?${params}`);
},
sendMessage: (id: string, data: CreateDmMessageRequest) =>
request<DmMessageWithUser>('POST', `/dm/${id}/messages`, data),
updateMessage: (id: string, data: UpdateMessageRequest) =>
@@ -339,6 +356,31 @@ export class BackspaceApiClient {
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/roles/${roleId}`),
};
this.search = {
channel: (channelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.from) qs.set('from', params.from);
if (params.has) qs.set('has', params.has);
if (params.before) qs.set('before', params.before);
if (params.after) qs.set('after', params.after);
if (params.offset !== undefined) qs.set('offset', String(params.offset));
if (params.limit !== undefined) qs.set('limit', String(params.limit));
return request<{ results: MessageWithUser[]; totalCount: number }>('GET', `/channels/${channelId}/search?${qs}`);
},
dm: (dmChannelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.from) qs.set('from', params.from);
if (params.has) qs.set('has', params.has);
if (params.before) qs.set('before', params.before);
if (params.after) qs.set('after', params.after);
if (params.offset !== undefined) qs.set('offset', String(params.offset));
if (params.limit !== undefined) qs.set('limit', String(params.limit));
return request<{ results: DmMessageWithUser[]; totalCount: number }>('GET', `/dm/${dmChannelId}/search?${qs}`);
},
};
this.explore = {
list: (q?: string, limit = 50, offset = 0) => {
const params = new URLSearchParams();
@@ -155,6 +155,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const content = (
<div
id={`msg-${message.id}`}
className={`group relative flex px-5 py-[3px] transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''} ${
isMentioned
? 'bg-accent-amber/10 border-l-2 border-l-accent-amber hover:bg-accent-amber/15'
@@ -13,6 +13,8 @@ const EMPTY_MESSAGES: MessageWithUser[] = [];
interface MessageListProps {
channelId: string;
jumpToMessageId?: string | null;
onJumpComplete?: () => void;
}
function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean {
@@ -38,10 +40,11 @@ function shouldShowDateDivider(prev: MessageWithUser | undefined, curr: MessageW
return prevDate !== currDate;
}
export function MessageList({ channelId }: MessageListProps) {
export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: MessageListProps) {
const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES;
const loadMessages = useChatStore((s) => s.loadMessages);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const loadMessagesAround = useChatStore((s) => s.loadMessagesAround);
const isLoading = useChatStore((s) => s.isLoading);
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
const ackChannel = useChatStore((s) => s.ackChannel);
@@ -118,6 +121,36 @@ export function MessageList({ channelId }: MessageListProps) {
return () => observer.disconnect();
}, [hasMessages, channelId]);
// Jump-to-message: scroll to target and highlight
useEffect(() => {
if (!jumpToMessageId) return;
const scrollToMessage = () => {
const el = document.getElementById(`msg-${jumpToMessageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('search-highlight');
setTimeout(() => el.classList.remove('search-highlight'), 2000);
onJumpComplete?.();
return true;
}
return false;
};
// Check if the message is already in the cache
if (scrollToMessage()) return;
// Not in cache — load messages around the target
loadMessagesAround(channelId, jumpToMessageId).then(() => {
// Wait for React to render the new messages
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToMessage();
});
});
});
}, [jumpToMessageId, channelId, loadMessagesAround, onJumpComplete]);
const handleScroll = useCallback(async () => {
const container = containerRef.current;
if (!container) return;
@@ -0,0 +1,333 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
import { Avatar } from '../ui/Avatar';
import type { MessageWithUser, DmMessageWithUser } from '@backspace/shared';
type AnyMessage = MessageWithUser | DmMessageWithUser;
interface SearchPopoverProps {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
channelId: string;
isDm: boolean;
onJumpToMessage: (messageId: string) => void;
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query.trim() || !text) return text;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = text.split(new RegExp(`(${escaped})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase()
? <mark key={i} className="bg-accent-primary/30 text-txt-primary rounded-sm px-0.5">{part}</mark>
: part
);
}
export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJumpToMessage }: SearchPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { style } = useFloatingPosition(anchorRef, popoverRef, {
placement: 'bottom',
offset: 8,
enabled: open,
});
const [query, setQuery] = useState('');
const [fromFilter, setFromFilter] = useState('');
const [hasFilter, setHasFilter] = useState('');
const [beforeFilter, setBeforeFilter] = useState('');
const [afterFilter, setAfterFilter] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [results, setResults] = useState<AnyMessage[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isSearching, setIsSearching] = useState(false);
const [offset, setOffset] = useState(0);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Reset state when channel changes or popover opens
useEffect(() => {
if (open) {
setQuery('');
setFromFilter('');
setHasFilter('');
setBeforeFilter('');
setAfterFilter('');
setResults([]);
setTotalCount(0);
setOffset(0);
setShowFilters(false);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open, channelId]);
// Click-outside handler
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open, onClose]);
// Escape handler
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
const doSearch = useCallback(async (searchOffset = 0) => {
const trimmed = query.trim();
if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) {
setResults([]);
setTotalCount(0);
return;
}
setIsSearching(true);
try {
const origin = getChannelOrigin(channelId);
const client = getApiForOrigin(origin);
const params = {
q: trimmed || undefined,
from: fromFilter || undefined,
has: hasFilter || undefined,
before: beforeFilter || undefined,
after: afterFilter || undefined,
offset: searchOffset,
limit: 25,
};
const data = isDm
? await client.search.dm(channelId, params)
: await client.search.channel(channelId, params);
if (searchOffset === 0) {
setResults(data.results as AnyMessage[]);
} else {
setResults(prev => [...prev, ...(data.results as AnyMessage[])]);
}
setTotalCount(data.totalCount);
setOffset(searchOffset + data.results.length);
} catch (err) {
console.error('Search failed:', err);
} finally {
setIsSearching(false);
}
}, [query, fromFilter, hasFilter, beforeFilter, afterFilter, channelId, isDm]);
// Debounced search on query/filter change
useEffect(() => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
doSearch(0);
}, 300);
return () => clearTimeout(debounceRef.current);
}, [doSearch]);
const handleLoadMore = () => {
doSearch(offset);
};
if (!open) return null;
return createPortal(
<div
ref={popoverRef}
style={style}
className="w-[420px] max-h-[500px] glass rounded-lg shadow-xl flex flex-col animate-fade-in"
>
{/* Search input */}
<div className="p-3 border-b border-white/[0.07]">
<div className="flex items-center gap-2 bg-surface-input rounded-lg px-3 py-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0">
<path d="M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..."
className="flex-1 bg-transparent text-txt-primary text-[14px] placeholder-txt-tertiary outline-none"
/>
{query && (
<button
onClick={() => setQuery('')}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="mt-2 text-[12px] text-txt-tertiary hover:text-txt-secondary transition-colors flex items-center gap-1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className={`transition-transform ${showFilters ? 'rotate-90' : ''}`}>
<path d="M10 17l5-5-5-5v10z" />
</svg>
Filters
{(fromFilter || hasFilter || beforeFilter || afterFilter) && (
<span className="w-1.5 h-1.5 rounded-full bg-accent-primary" />
)}
</button>
{/* Filters row */}
{showFilters && (
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">From</label>
<input
type="text"
value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)}
placeholder="username"
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary placeholder-txt-tertiary outline-none"
/>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Has</label>
<select
value={hasFilter}
onChange={(e) => setHasFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none appearance-none cursor-pointer"
>
<option value="">Any</option>
<option value="file">File</option>
<option value="image">Image</option>
<option value="link">Link</option>
</select>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Before</label>
<input
type="date"
value={beforeFilter}
onChange={(e) => setBeforeFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none"
/>
</div>
<div>
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">After</label>
<input
type="date"
value={afterFilter}
onChange={(e) => setAfterFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none"
/>
</div>
</div>
)}
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0">
{isSearching && results.length === 0 && (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-txt-tertiary border-t-transparent rounded-full animate-spin" />
</div>
)}
{!isSearching && results.length === 0 && (query || fromFilter || hasFilter || beforeFilter || afterFilter) && (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/50 mb-2">
<path d="M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" />
</svg>
<span className="text-txt-tertiary text-[13px]">No results found</span>
</div>
)}
{!query && !fromFilter && !hasFilter && !beforeFilter && !afterFilter && results.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<span className="text-txt-tertiary text-[13px]">Type to search messages in this channel</span>
</div>
)}
{results.length > 0 && (
<>
<div className="px-3 py-2 text-[11px] text-txt-tertiary font-medium">
{totalCount} result{totalCount !== 1 ? 's' : ''}
</div>
{results.map((msg) => (
<button
key={msg.id}
onClick={() => onJumpToMessage(msg.id)}
className="w-full px-3 py-2.5 hover:bg-interactive-hover transition-colors text-left flex items-start gap-2.5 group/result"
>
<div className="flex-shrink-0 mt-0.5">
<Avatar
src={msg.user?.avatar}
name={msg.user?.displayName ?? msg.user?.username ?? '?'}
size={28}
user={msg.user ?? undefined}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1.5">
<span className="text-[13px] font-semibold text-txt-primary truncate">
{msg.user?.displayName ?? msg.user?.username ?? 'Unknown'}
</span>
<span className="text-[10px] text-txt-tertiary flex-shrink-0">
{formatTime(msg.createdAt)}
</span>
</div>
<div className="text-[13px] text-txt-secondary leading-[1.4] line-clamp-2 mt-0.5">
{highlightMatch(msg.content ?? '', query)}
</div>
{msg.attachments && msg.attachments.length > 0 && (
<div className="flex items-center gap-1 mt-1 text-[11px] text-txt-tertiary">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H9v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S6 2.79 6 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
</svg>
{msg.attachments.length} attachment{msg.attachments.length !== 1 ? 's' : ''}
</div>
)}
</div>
</button>
))}
{results.length < totalCount && (
<div className="px-3 py-2 border-t border-white/[0.07]">
<button
onClick={handleLoadMore}
disabled={isSearching}
className="w-full py-1.5 text-[13px] text-accent-primary hover:text-accent-primary-hover transition-colors font-medium disabled:opacity-50"
>
{isSearching ? 'Loading...' : `Load more (${totalCount - results.length} remaining)`}
</button>
</div>
)}
</>
)}
</div>
</div>,
document.body,
);
}
@@ -17,6 +17,8 @@ import { wsSend } from '../../hooks/useWebSocket';
import { MemberListToggleButton } from './MemberListToggleButton';
import { isSelf } from '../../utils/identity';
import { joinVoiceChannel } from '../../utils/voice';
import { SearchPopover } from '../chat/SearchPopover';
import { isDmChannel } from '../../stores/spaceStore';
export function MainContent() {
// 1. ALL HOOKS AT THE TOP
@@ -40,6 +42,14 @@ export function MainContent() {
const openModal = useUIStore((s) => s.openModal);
const voiceContainerRef = useRef<HTMLDivElement>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
// Reset search when channel changes
useEffect(() => {
setSearchOpen(false);
}, [currentChannelId]);
// Handle actual browser fullscreen API
useEffect(() => {
@@ -198,25 +208,28 @@ export function MainContent() {
</button>
<MemberListToggleButton />
<div className="w-[1px] h-5 bg-border-soft mx-1" />
<button className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded-[6px] hover:bg-interactive-hover" title="Search">
<button
ref={searchButtonRef}
onClick={() => setSearchOpen(!searchOpen)}
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[6px] ${searchOpen ? 'text-txt-primary bg-interactive-active' : 'text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover'}`}
title="Search"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" />
</svg>
</button>
<button className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded-[6px] hover:bg-interactive-hover" title="Inbox">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" />
</svg>
</button>
<button className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded-[6px] hover:bg-interactive-hover" title="Help">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
</svg>
</button>
</div>
</div>
<MessageList channelId={currentChannelId} />
<MessageList channelId={currentChannelId} jumpToMessageId={jumpToMessageId} onJumpComplete={() => setJumpToMessageId(null)} />
<MessageInput channelId={currentChannelId} channelName={`@${dmName}`} />
<SearchPopover
open={searchOpen}
onClose={() => setSearchOpen(false)}
anchorRef={searchButtonRef}
channelId={currentChannelId}
isDm={true}
onJumpToMessage={(id) => { setJumpToMessageId(id); setSearchOpen(false); }}
/>
</div>
);
}
@@ -324,11 +337,6 @@ export function MainContent() {
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded-[6px] hover:bg-interactive-hover" title="Threads">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.43 21a.996.996 0 01-.98-.8l-.79-4.34H2.5a1 1 0 110-2h.93l-.55-3H1.5a1 1 0 010-2h1.15L1.87 4.86a1 1 0 011.96-.72L4.6 8.86h3.32l-.78-4.72a1 1 0 011.96-.28l.84 5H13.5a1 1 0 110 2h-3.33l.55 3H13.5a1 1 0 110 2h-2.55l.72 3.94a1 1 0 01-.79 1.16 1.034 1.034 0 01-.18.02.996.996 0 01-.98-.82L8.95 15.86H5.63l.72 3.94A1 1 0 015.43 21zM5.86 10.86l.55 3h3.32l-.55-3H5.86z" />
</svg>
</button>
<button className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded-[6px] hover:bg-interactive-hover" title="Notification Settings">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
@@ -341,25 +349,28 @@ export function MainContent() {
</button>
<MemberListToggleButton />
<div className="w-[1px] h-5 bg-border-soft mx-1" />
<button className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded-[6px] hover:bg-interactive-hover" title="Search">
<button
ref={searchButtonRef}
onClick={() => setSearchOpen(!searchOpen)}
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[6px] ${searchOpen ? 'text-txt-primary bg-interactive-active' : 'text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover'}`}
title="Search"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" />
</svg>
</button>
<button className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded-[6px] hover:bg-interactive-hover" title="Inbox">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" />
</svg>
</button>
<button className="w-8 h-8 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded-[6px] hover:bg-interactive-hover" title="Help">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
</svg>
</button>
</div>
</div>
<MessageList channelId={currentChannelId} />
<MessageList channelId={currentChannelId} jumpToMessageId={jumpToMessageId} onJumpComplete={() => setJumpToMessageId(null)} />
<MessageInput channelId={currentChannelId} channelName={channel.name} />
<SearchPopover
open={searchOpen}
onClose={() => setSearchOpen(false)}
anchorRef={searchButtonRef}
channelId={currentChannelId}
isDm={false}
onJumpToMessage={(id) => { setJumpToMessageId(id); setSearchOpen(false); }}
/>
</div>
);
}
+29
View File
@@ -48,6 +48,7 @@ interface ChatState {
removeReaction: (messageId: string, emoji: string) => void;
onReactionAdded: (messageId: string, reaction: any) => void;
onReactionRemoved: (messageId: string, userId: string, emoji: string) => void;
loadMessagesAround: (channelId: string, messageId: string) => Promise<void>;
setTyping: (channelId: string, userId: string, username: string) => void;
clearTyping: (channelId: string, userId: string) => void;
getMessages: (channelId: string) => MessageWithUser[];
@@ -198,6 +199,34 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
},
loadMessagesAround: async (channelId: string, messageId: string) => {
const isDm = isDmChannel(channelId);
if (!isDm && !useSpaceStore.getState().channelOriginMap.has(channelId)) return;
try {
const origin = getChannelOrigin(channelId);
const client = getApiForOrigin(origin);
const messages = isDm
? await client.dm.messagesAround(channelId, messageId)
: await client.channels.messagesAround(channelId, messageId);
if (origin) {
for (const msg of messages) normalizeMessageAssets(msg, origin);
}
set((state) => {
const newMessages = new Map(state.messages);
newMessages.set(channelId, messages as MessageWithUser[]);
const newHasMore = new Map(state.hasMore);
newHasMore.set(channelId, true);
const newAccessTimes = new Map(state.channelAccessTimes);
newAccessTimes.set(channelId, Date.now());
return { messages: newMessages, hasMore: newHasMore, channelAccessTimes: newAccessTimes };
});
} catch (err) {
console.error('Failed to load messages around:', err);
}
},
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
const replyToId = get().replyTo?.id;
const isDm = isDmChannel(channelId);
+6
View File
@@ -254,3 +254,9 @@
.animate-gradient-pulse {
animation: gradientPulse 4s ease-in-out infinite;
}
@keyframes search-flash {
0% { background-color: rgba(124, 108, 246, 0.2); }
100% { background-color: transparent; }
}
.search-highlight { animation: search-flash 2s ease-out; }