feat: Initialize the Electron frontend with core UI components and integrate Convex backend services.

This commit is contained in:
Bryan1029384756
2026-02-10 18:29:42 -06:00
parent 34e9790db9
commit 17790afa9b
64 changed files with 149216 additions and 628 deletions

View File

@@ -15,6 +15,7 @@ import type * as dms from "../dms.js";
import type * as files from "../files.js";
import type * as gifs from "../gifs.js";
import type * as invites from "../invites.js";
import type * as members from "../members.js";
import type * as messages from "../messages.js";
import type * as reactions from "../reactions.js";
import type * as roles from "../roles.js";
@@ -36,6 +37,7 @@ declare const fullApi: ApiFromModules<{
files: typeof files;
gifs: typeof gifs;
invites: typeof invites;
members: typeof members;
messages: typeof messages;
reactions: typeof reactions;
roles: typeof roles;

View File

@@ -197,14 +197,62 @@ export const getPublicKeys = query({
id: v.string(),
username: v.string(),
public_identity_key: v.string(),
status: v.optional(v.string()),
avatarUrl: v.optional(v.union(v.string(), v.null())),
aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()),
})
),
handler: async (ctx) => {
const users = await ctx.db.query("userProfiles").collect();
return users.map((u) => ({
id: u._id,
username: u.username,
public_identity_key: u.publicIdentityKey,
}));
const results = [];
for (const u of users) {
let avatarUrl: string | null = null;
if (u.avatarStorageId) {
avatarUrl = await ctx.storage.getUrl(u.avatarStorageId);
}
results.push({
id: u._id,
username: u.username,
public_identity_key: u.publicIdentityKey,
status: u.status || "online",
avatarUrl,
aboutMe: u.aboutMe,
customStatus: u.customStatus,
});
}
return results;
},
});
// Update user profile (aboutMe, avatar, customStatus)
export const updateProfile = mutation({
args: {
userId: v.id("userProfiles"),
aboutMe: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")),
customStatus: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const patch: Record<string, unknown> = {};
if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe;
if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId;
if (args.customStatus !== undefined) patch.customStatus = args.customStatus;
await ctx.db.patch(args.userId, patch);
return null;
},
});
// Update user status
export const updateStatus = mutation({
args: {
userId: v.id("userProfiles"),
status: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, { status: args.status });
return null;
},
});

View File

@@ -31,13 +31,16 @@ export const list = query({
_creationTime: v.number(),
name: v.string(),
type: v.string(),
category: v.optional(v.string()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
})
),
handler: async (ctx) => {
const channels = await ctx.db.query("channels").collect();
return channels
.filter((c) => c.type !== "dm")
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name));
},
});
@@ -50,6 +53,9 @@ export const get = query({
_creationTime: v.number(),
name: v.string(),
type: v.string(),
category: v.optional(v.string()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}),
v.null()
),
@@ -63,6 +69,9 @@ export const create = mutation({
args: {
name: v.string(),
type: v.optional(v.string()),
category: v.optional(v.string()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
},
returns: v.object({ id: v.id("channels") }),
handler: async (ctx, args) => {
@@ -82,12 +91,30 @@ export const create = mutation({
const id = await ctx.db.insert("channels", {
name: args.name,
type: args.type || "text",
category: args.category,
topic: args.topic,
position: args.position,
});
return { id };
},
});
// Update channel topic
export const updateTopic = mutation({
args: {
id: v.id("channels"),
topic: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.id);
if (!channel) throw new Error("Channel not found");
await ctx.db.patch(args.id, { topic: args.topic });
return null;
},
});
// Rename channel
export const rename = mutation({
args: {
@@ -99,6 +126,9 @@ export const rename = mutation({
_creationTime: v.number(),
name: v.string(),
type: v.string(),
category: v.optional(v.string()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}),
handler: async (ctx, args) => {
if (!args.name.trim()) {

View File

@@ -49,6 +49,7 @@ export const listDMs = query({
channel_name: v.string(),
other_user_id: v.string(),
other_username: v.string(),
other_user_status: v.optional(v.string()),
})
),
handler: async (ctx, args) => {
@@ -78,6 +79,7 @@ export const listDMs = query({
channel_name: channel.name,
other_user_id: otherUser._id as string,
other_username: otherUser.username,
other_user_status: otherUser.status || "online",
};
})
);

63
convex/members.ts Normal file
View File

@@ -0,0 +1,63 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getChannelMembers = query({
args: {
channelId: v.id("channels"),
},
returns: v.any(),
handler: async (ctx, args) => {
const channelKeyDocs = await ctx.db
.query("channelKeys")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const seenUsers = new Set<string>();
const members = [];
for (const doc of channelKeyDocs) {
const odId = doc.userId.toString();
if (seenUsers.has(odId)) continue;
seenUsers.add(odId);
const user = await ctx.db.get(doc.userId);
if (!user) continue;
const userRoleDocs = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", doc.userId))
.collect();
const roles = [];
for (const ur of userRoleDocs) {
const role = await ctx.db.get(ur.roleId);
if (role) {
roles.push({
id: role._id,
name: role.name,
color: role.color,
position: role.position,
isHoist: role.isHoist,
});
}
}
let avatarUrl: string | null = null;
if (user.avatarStorageId) {
avatarUrl = await ctx.storage.getUrl(user.avatarStorageId);
}
members.push({
id: user._id,
username: user.username,
status: user.status || "online",
roles: roles.sort((a, b) => b.position - a.position),
avatarUrl,
aboutMe: user.aboutMe,
customStatus: user.customStatus,
});
}
return members;
},
});

View File

@@ -20,6 +20,11 @@ export const list = query({
result.page.map(async (msg) => {
const sender = await ctx.db.get(msg.senderId);
let avatarUrl: string | null = null;
if (sender?.avatarStorageId) {
avatarUrl = await ctx.storage.getUrl(sender.avatarStorageId);
}
const reactionDocs = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
@@ -34,6 +39,23 @@ export const list = query({
}
}
let replyToUsername: 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";
replyToContent = repliedMsg.ciphertext;
replyToNonce = repliedMsg.nonce;
if (repliedSender?.avatarStorageId) {
replyToAvatarUrl = await ctx.storage.getUrl(repliedSender.avatarStorageId);
}
}
}
return {
id: msg._id,
channel_id: msg.channelId,
@@ -45,7 +67,15 @@ export const list = query({
created_at: new Date(msg._creationTime).toISOString(),
username: sender?.username || "Unknown",
public_signing_key: sender?.publicSigningKey || "",
avatarUrl,
reactions: Object.keys(reactions).length > 0 ? reactions : null,
replyToId: msg.replyTo || null,
replyToUsername,
replyToContent,
replyToNonce,
replyToAvatarUrl,
editedAt: msg.editedAt || null,
pinned: msg.pinned || false,
};
})
);
@@ -62,6 +92,7 @@ export const send = mutation({
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) => {
@@ -72,11 +103,74 @@ export const send = mutation({
nonce: args.nonce,
signature: args.signature,
keyVersion: args.keyVersion,
replyTo: args.replyTo,
});
return { id };
},
});
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"),
},
returns: v.any(),
handler: async (ctx, args) => {
const allMessages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const pinned = allMessages.filter((m) => m.pinned === true);
return Promise.all(
pinned.map(async (msg) => {
const sender = await ctx.db.get(msg.senderId);
return {
id: msg._id,
ciphertext: msg.ciphertext,
nonce: msg.nonce,
signature: msg.signature,
key_version: msg.keyVersion,
created_at: new Date(msg._creationTime).toISOString(),
username: sender?.username || "Unknown",
public_signing_key: sender?.publicSigningKey || "",
};
})
);
},
});
export const remove = mutation({
args: { id: v.id("messages") },
returns: v.null(),

View File

@@ -11,11 +11,18 @@ export default defineSchema({
publicSigningKey: v.string(),
encryptedPrivateKeys: v.string(),
isAdmin: v.boolean(),
status: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")),
aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()),
}).index("by_username", ["username"]),
channels: defineTable({
name: v.string(),
type: v.string(), // 'text' | 'voice' | 'dm'
category: v.optional(v.string()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}).index("by_name", ["name"]),
messages: defineTable({
@@ -25,6 +32,9 @@ export default defineSchema({
nonce: v.string(),
signature: v.string(),
keyVersion: v.number(),
replyTo: v.optional(v.id("messages")),
editedAt: v.optional(v.number()),
pinned: v.optional(v.boolean()),
}).index("by_channel", ["channelId"]),
messageReactions: defineTable({