feat: implement initial Electron chat application with core UI components and Convex backend integration.
All checks were successful
Build and Release / build-and-release (push) Successful in 11m1s
All checks were successful
Build and Release / build-and-release (push) Successful in 11m1s
This commit is contained in:
@@ -4,6 +4,68 @@ import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
import { getRolesForUser } from "./roles";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 }> = {};
|
||||
for (const r of reactionDocs) {
|
||||
const entry = (reactions[r.emoji] ??= { count: 0, me: false });
|
||||
entry.count++;
|
||||
if (userId && r.userId === userId) {
|
||||
entry.me = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 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",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -19,67 +81,7 @@ export const list = query({
|
||||
.paginate(args.paginationOpts);
|
||||
|
||||
const enrichedPage = await Promise.all(
|
||||
result.page.map(async (msg) => {
|
||||
const sender = await ctx.db.get(msg.senderId);
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
if (sender?.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId);
|
||||
}
|
||||
|
||||
const reactionDocs = await ctx.db
|
||||
.query("messageReactions")
|
||||
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
|
||||
.collect();
|
||||
|
||||
const reactions: Record<string, { count: number; me: boolean }> = {};
|
||||
for (const r of reactionDocs) {
|
||||
const entry = (reactions[r.emoji] ??= { count: 0, me: false });
|
||||
entry.count++;
|
||||
if (args.userId && r.userId === args.userId) {
|
||||
entry.me = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 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",
|
||||
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,
|
||||
};
|
||||
})
|
||||
result.page.map((msg) => enrichMessage(ctx, msg, args.userId))
|
||||
);
|
||||
|
||||
return { ...result, page: enrichedPage };
|
||||
@@ -145,30 +147,19 @@ export const pin = mutation({
|
||||
export const listPinned = query({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
userId: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.any(),
|
||||
handler: async (ctx, args) => {
|
||||
const allMessages = await ctx.db
|
||||
const pinned = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
||||
.withIndex("by_channel_pinned", (q) =>
|
||||
q.eq("channelId", args.channelId).eq("pinned", true)
|
||||
)
|
||||
.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 || "",
|
||||
};
|
||||
})
|
||||
pinned.map((msg) => enrichMessage(ctx, msg, args.userId))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,7 +42,8 @@ export default defineSchema({
|
||||
replyTo: v.optional(v.id("messages")),
|
||||
editedAt: v.optional(v.number()),
|
||||
pinned: v.optional(v.boolean()),
|
||||
}).index("by_channel", ["channelId"]),
|
||||
}).index("by_channel", ["channelId"])
|
||||
.index("by_channel_pinned", ["channelId", "pinned"]),
|
||||
|
||||
messageReactions: defineTable({
|
||||
messageId: v.id("messages"),
|
||||
@@ -110,6 +111,7 @@ export default defineSchema({
|
||||
isDeafened: v.boolean(),
|
||||
isScreenSharing: v.boolean(),
|
||||
isServerMuted: v.boolean(),
|
||||
watchingStream: v.optional(v.id("userProfiles")),
|
||||
})
|
||||
.index("by_channel", ["channelId"])
|
||||
.index("by_user", ["userId"]),
|
||||
|
||||
@@ -70,6 +70,16 @@ export const updateState = mutation({
|
||||
Object.entries(updates).filter(([, val]) => val !== undefined)
|
||||
);
|
||||
await ctx.db.patch(existing._id, filtered);
|
||||
|
||||
// When a user stops screen sharing, clear all viewers watching their stream
|
||||
if (args.isScreenSharing === false) {
|
||||
const allStates = await ctx.db.query("voiceStates").collect();
|
||||
for (const s of allStates) {
|
||||
if (s.watchingStream === args.userId) {
|
||||
await ctx.db.patch(s._id, { watchingStream: undefined });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -105,6 +115,28 @@ export const serverMute = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const setWatchingStream = mutation({
|
||||
args: {
|
||||
userId: v.id("userProfiles"),
|
||||
watchingStream: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("voiceStates")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
watchingStream: args.watchingStream ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const getAll = query({
|
||||
args: {},
|
||||
returns: v.any(),
|
||||
@@ -119,6 +151,7 @@ export const getAll = query({
|
||||
isScreenSharing: boolean;
|
||||
isServerMuted: boolean;
|
||||
avatarUrl: string | null;
|
||||
watchingStream: string | null;
|
||||
}>> = {};
|
||||
|
||||
for (const s of states) {
|
||||
@@ -136,6 +169,7 @@ export const getAll = query({
|
||||
isScreenSharing: s.isScreenSharing,
|
||||
isServerMuted: s.isServerMuted,
|
||||
avatarUrl,
|
||||
watchingStream: s.watchingStream ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,6 +211,14 @@ export const afkMove = mutation({
|
||||
isServerMuted: currentState.isServerMuted,
|
||||
});
|
||||
|
||||
// Clear viewers watching the moved user's stream (screen sharing stops on AFK move)
|
||||
const allStates = await ctx.db.query("voiceStates").collect();
|
||||
for (const s of allStates) {
|
||||
if (s.watchingStream === args.userId) {
|
||||
await ctx.db.patch(s._id, { watchingStream: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user