feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
This commit is contained in:
@@ -4,6 +4,8 @@ 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);
|
||||
|
||||
@@ -12,19 +14,112 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
||||
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();
|
||||
|
||||
const reactions: Record<string, { count: number; me: boolean }> = {};
|
||||
// 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) {
|
||||
const entry = (reactions[r.emoji] ??= { count: 0, me: false });
|
||||
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;
|
||||
@@ -58,7 +153,8 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
||||
displayName: sender?.displayName || null,
|
||||
public_signing_key: sender?.publicSigningKey || "",
|
||||
avatarUrl,
|
||||
reactions: Object.keys(reactions).length > 0 ? reactions : null,
|
||||
senderRoleColor,
|
||||
reactions: reactions.length > 0 ? reactions : null,
|
||||
replyToId: msg.replyTo || null,
|
||||
replyToUsername,
|
||||
replyToDisplayName,
|
||||
@@ -92,6 +188,43 @@ export const list = query({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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"),
|
||||
|
||||
Reference in New Issue
Block a user