Files
DiscordClone/convex/messages.ts
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

493 lines
14 KiB
TypeScript

import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles";
const DEFAULT_ROLE_COLOR = "#99aab5";
async function enrichMessage(ctx: any, msg: any, userId?: any) {
const sender = await ctx.db.get(msg.senderId);
let avatarUrl: string | null = null;
if (sender?.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId);
}
// Highest-position role with a non-default colour — mirrors how
// Discord tints usernames in chat. The Owner role is deliberately
// skipped so owners fall through to the next non-default role's
// colour (per product decision: we don't want every owner's name
// to glow in the bootstrap pink). Default grey (`#99aab5`) is
// treated as "no colour" so regular users fall back to
// `--text-primary`.
let senderRoleColor: string | null = null;
try {
const senderRoleDocs = await ctx.db
.query("userRoles")
.withIndex("by_user", (q: any) => q.eq("userId", msg.senderId))
.collect();
let best: { position: number; color: string } | null = null;
for (const ur of senderRoleDocs) {
const role = await ctx.db.get(ur.roleId);
if (!role) continue;
if ((role as any).name === "Owner") continue;
const color = (role as any).color as string | undefined;
const position = (role as any).position ?? 0;
if (!color || color.toLowerCase() === DEFAULT_ROLE_COLOR) continue;
if (!best || position > best.position) {
best = { position, color };
}
}
senderRoleColor = best?.color ?? null;
} catch {
senderRoleColor = null;
}
const reactionDocs = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q: any) => q.eq("messageId", msg._id))
.collect();
// Accumulate into a Map so we don't use emoji surrogates as object
// field names — Convex's return-value validator rejects non-ASCII
// field names, which is what caused "Field name 👍 has invalid
// character" errors on any channel with a unicode reaction.
//
// For each emoji we also collect a capped list of reactor profiles
// (up to MAX_REACTION_USERS) so the client can render the hover
// tooltip + the full reactions modal without a second round-trip.
// Reactor profiles are cached per-message so the same user picked
// for multiple emojis only costs one db lookup.
const MAX_REACTION_USERS = 100;
const profileCache = new Map<
string,
{ userId: string; username: string; displayName: string | null }
>();
const resolveProfile = async (reactorUserId: any) => {
const key = String(reactorUserId);
const cached = profileCache.get(key);
if (cached) return cached;
const profile = await ctx.db.get(reactorUserId);
const shaped = {
userId: key,
username: profile?.username || "Unknown",
displayName: profile?.displayName || null,
};
profileCache.set(key, shaped);
return shaped;
};
interface ReactionAccumulator {
count: number;
me: boolean;
users: Array<{
userId: string;
username: string;
displayName: string | null;
}>;
}
const reactionMap = new Map<string, ReactionAccumulator>();
for (const r of reactionDocs) {
let entry = reactionMap.get(r.emoji);
if (!entry) {
entry = { count: 0, me: false, users: [] };
reactionMap.set(r.emoji, entry);
}
entry.count++;
if (userId && r.userId === userId) {
entry.me = true;
}
if (entry.users.length < MAX_REACTION_USERS) {
entry.users.push(await resolveProfile(r.userId));
}
}
const reactions: Array<{
emoji: string;
count: number;
me: boolean;
users: Array<{
userId: string;
username: string;
displayName: string | null;
}>;
}> = [];
reactionMap.forEach((info, emoji) => {
reactions.push({
emoji,
count: info.count,
me: info.me,
users: info.users,
});
});
let replyToUsername: string | null = null;
let replyToDisplayName: string | null = null;
let replyToContent: string | null = null;
let replyToNonce: string | null = null;
let replyToAvatarUrl: string | null = null;
if (msg.replyTo) {
const repliedMsg = await ctx.db.get(msg.replyTo);
if (repliedMsg) {
const repliedSender = await ctx.db.get(repliedMsg.senderId);
replyToUsername = repliedSender?.username || "Unknown";
replyToDisplayName = repliedSender?.displayName || null;
replyToContent = repliedMsg.ciphertext;
replyToNonce = repliedMsg.nonce;
if (repliedSender?.avatarStorageId) {
replyToAvatarUrl = await getPublicStorageUrl(ctx, repliedSender.avatarStorageId);
}
}
}
return {
id: msg._id,
channel_id: msg.channelId,
sender_id: msg.senderId,
ciphertext: msg.ciphertext,
nonce: msg.nonce,
signature: msg.signature,
key_version: msg.keyVersion,
created_at: new Date(msg._creationTime).toISOString(),
username: sender?.username || "Unknown",
displayName: sender?.displayName || null,
public_signing_key: sender?.publicSigningKey || "",
avatarUrl,
senderRoleColor,
reactions: reactions.length > 0 ? reactions : null,
replyToId: msg.replyTo || null,
replyToUsername,
replyToDisplayName,
replyToContent,
replyToNonce,
replyToAvatarUrl,
editedAt: msg.editedAt || null,
pinned: msg.pinned || false,
};
}
export const list = query({
args: {
paginationOpts: paginationOptsValidator,
channelId: v.id("channels"),
userId: v.optional(v.id("userProfiles")),
},
returns: v.any(),
handler: async (ctx, args) => {
const result = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.paginate(args.paginationOpts);
const enrichedPage = await Promise.all(
result.page.map((msg) => enrichMessage(ctx, msg, args.userId))
);
return { ...result, page: enrichedPage };
},
});
/**
* Pull the latest N messages from each of the given channel IDs in a
* single round-trip. Used by SearchPanel to scan across every channel
* the user can read without spinning up N independent useQuery hooks
* (React would error on hook-count drift between renders).
*
* `perChannelLimit` is clamped to 200 server-side to keep payload
* bounded even if the client over-asks.
*/
export const searchScan = query({
args: {
channelIds: v.array(v.id("channels")),
perChannelLimit: v.optional(v.number()),
userId: v.optional(v.id("userProfiles")),
},
returns: v.any(),
handler: async (ctx, args) => {
const limit = Math.min(Math.max(args.perChannelLimit ?? 100, 1), 200);
const out: Array<{
channelId: string;
messages: any[];
}> = [];
for (const channelId of args.channelIds) {
const rows = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(limit);
const enriched = await Promise.all(
rows.map((msg) => enrichMessage(ctx, msg, args.userId)),
);
out.push({ channelId, messages: enriched });
}
return out;
},
});
export const send = mutation({
args: {
channelId: v.id("channels"),
senderId: v.id("userProfiles"),
ciphertext: v.string(),
nonce: v.string(),
signature: v.string(),
keyVersion: v.number(),
replyTo: v.optional(v.id("messages")),
},
returns: v.object({ id: v.id("messages") }),
handler: async (ctx, args) => {
const id = await ctx.db.insert("messages", {
channelId: args.channelId,
senderId: args.senderId,
ciphertext: args.ciphertext,
nonce: args.nonce,
signature: args.signature,
keyVersion: args.keyVersion,
replyTo: args.replyTo,
});
return { id };
},
});
export const sendBatch = mutation({
args: {
messages: v.array(v.object({
channelId: v.id("channels"),
senderId: v.id("userProfiles"),
ciphertext: v.string(),
nonce: v.string(),
signature: v.string(),
keyVersion: v.number(),
})),
},
returns: v.object({ count: v.number() }),
handler: async (ctx, args) => {
for (const msg of args.messages) {
await ctx.db.insert("messages", { ...msg });
}
return { count: args.messages.length };
},
});
export const edit = mutation({
args: {
id: v.id("messages"),
ciphertext: v.string(),
nonce: v.string(),
signature: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.id, {
ciphertext: args.ciphertext,
nonce: args.nonce,
signature: args.signature,
editedAt: Date.now(),
});
return null;
},
});
export const pin = mutation({
args: {
id: v.id("messages"),
pinned: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { pinned: args.pinned });
return null;
},
});
export const listPinned = query({
args: {
channelId: v.id("channels"),
userId: v.optional(v.id("userProfiles")),
},
returns: v.any(),
handler: async (ctx, args) => {
const pinned = await ctx.db
.query("messages")
.withIndex("by_channel_pinned", (q) =>
q.eq("channelId", args.channelId).eq("pinned", true)
)
.collect();
return Promise.all(
pinned.map((msg) => enrichMessage(ctx, msg, args.userId))
);
},
});
// Slim paginated query for bulk search index rebuilding.
// Skips reactions, avatars, reply resolution — only resolves sender username.
export const fetchBulkPage = query({
args: {
channelId: v.id("channels"),
paginationOpts: paginationOptsValidator,
},
returns: v.any(),
handler: async (ctx, args) => {
const result = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("asc")
.paginate(args.paginationOpts);
const enrichedPage = await Promise.all(
result.page.map(async (msg) => {
const sender = await ctx.db.get(msg.senderId);
return {
id: msg._id,
channel_id: msg.channelId,
sender_id: msg.senderId,
username: sender?.username || "Unknown",
ciphertext: msg.ciphertext,
nonce: msg.nonce,
created_at: new Date(msg._creationTime).toISOString(),
pinned: msg.pinned || false,
replyToId: msg.replyTo || null,
};
})
);
return { ...result, page: enrichedPage };
},
});
export const listAround = query({
args: {
channelId: v.id("channels"),
messageId: v.id("messages"),
userId: v.optional(v.id("userProfiles")),
},
returns: v.any(),
handler: async (ctx, args) => {
const target = await ctx.db.get(args.messageId);
if (!target || target.channelId !== args.channelId) {
return { messages: [], hasOlder: false, hasNewer: false, targetFound: false };
}
const targetTime = target._creationTime;
const before = await ctx.db
.query("messages")
.withIndex("by_channel", (q) =>
q.eq("channelId", args.channelId).lt("_creationTime", targetTime)
)
.order("desc")
.take(26);
const after = await ctx.db
.query("messages")
.withIndex("by_channel", (q) =>
q.eq("channelId", args.channelId).gt("_creationTime", targetTime)
)
.order("asc")
.take(26);
const hasOlder = before.length > 25;
const hasNewer = after.length > 25;
const olderMessages = before.slice(0, 25).reverse();
const newerMessages = after.slice(0, 25);
const allRaw = [...olderMessages, target, ...newerMessages];
const messages = await Promise.all(
allRaw.map((msg) => enrichMessage(ctx, msg, args.userId))
);
return { messages, hasOlder, hasNewer, targetFound: true };
},
});
export const listBefore = query({
args: {
channelId: v.id("channels"),
beforeTimestamp: v.number(),
userId: v.optional(v.id("userProfiles")),
limit: v.optional(v.number()),
},
returns: v.any(),
handler: async (ctx, args) => {
const limit = args.limit ?? 50;
const rows = await ctx.db
.query("messages")
.withIndex("by_channel", (q) =>
q.eq("channelId", args.channelId).lt("_creationTime", args.beforeTimestamp)
)
.order("desc")
.take(limit + 1);
const hasMore = rows.length > limit;
const page = rows.slice(0, limit);
const messages = await Promise.all(
page.reverse().map((msg) => enrichMessage(ctx, msg, args.userId))
);
return { messages, hasMore };
},
});
export const listAfter = query({
args: {
channelId: v.id("channels"),
afterTimestamp: v.number(),
userId: v.optional(v.id("userProfiles")),
limit: v.optional(v.number()),
},
returns: v.any(),
handler: async (ctx, args) => {
const limit = args.limit ?? 50;
const rows = await ctx.db
.query("messages")
.withIndex("by_channel", (q) =>
q.eq("channelId", args.channelId).gt("_creationTime", args.afterTimestamp)
)
.order("asc")
.take(limit + 1);
const hasMore = rows.length > limit;
const page = rows.slice(0, limit);
const messages = await Promise.all(
page.map((msg) => enrichMessage(ctx, msg, args.userId))
);
return { messages, hasMore };
},
});
export const remove = mutation({
args: { id: v.id("messages"), userId: v.id("userProfiles") },
returns: v.null(),
handler: async (ctx, args) => {
const message = await ctx.db.get(args.id);
if (!message) throw new Error("Message not found");
const isSender = message.senderId === args.userId;
if (!isSender) {
const roles = await getRolesForUser(ctx, args.userId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.manage_messages
);
if (!canManage) {
throw new Error("Not authorized to delete this message");
}
}
const reactions = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", args.id))
.collect();
for (const r of reactions) {
await ctx.db.delete(r._id);
}
await ctx.db.delete(args.id);
return null;
},
});