From 6813bb40dc4a06a449e6982fd6ec2b3660280989 Mon Sep 17 00:00:00 2001 From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:14:27 -0500 Subject: [PATCH] 1.1.00 --- apps/android/android/app/build.gradle | 2 +- apps/electron/package.json | 2 +- apps/web/package.json | 2 +- convex/_generated/api.d.ts | 6 + convex/auth.ts | 71 ++- convex/authActions.ts | 109 +++++ convex/authGuard.ts | 63 +++ convex/invites.ts | 6 +- convex/links.ts | 37 +- convex/messageActions.ts | 109 +++++ convex/messages.ts | 55 ++- convex/schema.ts | 3 +- convex/typing.ts | 19 +- convex/voice.ts | 122 ++++- packages/platform-web/src/idle.js | 113 ++++- packages/platform-web/src/session.js | 24 +- packages/platform-web/src/settings.js | 30 +- packages/shared/package.json | 2 +- .../shared/src/components/auth/LoginPage.tsx | 14 +- .../components/channel/ChannelTextarea.tsx | 44 +- .../DeleteConfirmationModal.module.css | 99 ++++ .../channel/DeleteConfirmationModal.tsx | 116 +++++ .../channel/EncryptedAttachment.tsx | 63 ++- .../src/components/channel/GifPicker.tsx | 15 +- .../src/components/channel/LinkEmbed.tsx | 215 +++++++-- .../src/components/channel/MessageGroup.tsx | 66 ++- .../src/components/channel/Messages.tsx | 443 ++++++++++++++---- .../src/components/channel/PausedGif.tsx | 53 ++- .../channel/PinConfirmationModal.tsx | 27 +- .../components/channel/PinnedMessageRow.tsx | 60 ++- .../src/components/channel/TwemojiImg.tsx | 12 +- .../layout/MobileSetStatusSheet.tsx | 23 +- .../layout/UserAreaProfilePopout.tsx | 26 +- .../components/member/MemberListContainer.tsx | 21 +- .../components/settings/UserSettingsModal.tsx | 27 +- packages/shared/src/contexts/VoiceContext.jsx | 369 +++++++++++---- packages/shared/src/hooks/useLogout.ts | 11 + packages/shared/src/platform/types.js | 6 +- packages/shared/src/utils/messageUrls.ts | 33 ++ packages/shared/src/utils/userPreferences.js | 97 +++- 40 files changed, 2228 insertions(+), 387 deletions(-) create mode 100644 convex/authActions.ts create mode 100644 convex/authGuard.ts create mode 100644 convex/messageActions.ts create mode 100644 packages/shared/src/components/channel/DeleteConfirmationModal.module.css create mode 100644 packages/shared/src/components/channel/DeleteConfirmationModal.tsx create mode 100644 packages/shared/src/utils/messageUrls.ts diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle index 345f2f2..3152ad9 100644 --- a/apps/android/android/app/build.gradle +++ b/apps/android/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 27 - versionName "1.0.90" + versionName "1.1.00" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/apps/electron/package.json b/apps/electron/package.json index 3f193ab..da9028d 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.90", + "version": "1.1.00", "description": "Brycord - Electron app", "author": "Moyettes", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index 7649e52..a7cc41c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/web", "private": true, - "version": "1.0.90", + "version": "1.1.00", "type": "module", "scripts": { "dev": "vite", diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index ebff6e8..d123040 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,6 +9,8 @@ */ import type * as auth from "../auth.js"; +import type * as authActions from "../authActions.js"; +import type * as authGuard from "../authGuard.js"; import type * as categories from "../categories.js"; import type * as channelKeys from "../channelKeys.js"; import type * as channels from "../channels.js"; @@ -19,6 +21,7 @@ import type * as gifs from "../gifs.js"; import type * as invites from "../invites.js"; import type * as links from "../links.js"; import type * as members from "../members.js"; +import type * as messageActions from "../messageActions.js"; import type * as messages from "../messages.js"; import type * as polls from "../polls.js"; import type * as presence from "../presence.js"; @@ -41,6 +44,8 @@ import type { declare const fullApi: ApiFromModules<{ auth: typeof auth; + authActions: typeof authActions; + authGuard: typeof authGuard; categories: typeof categories; channelKeys: typeof channelKeys; channels: typeof channels; @@ -51,6 +56,7 @@ declare const fullApi: ApiFromModules<{ invites: typeof invites; links: typeof links; members: typeof members; + messageActions: typeof messageActions; messages: typeof messages; polls: typeof polls; presence: typeof presence; diff --git a/convex/auth.ts b/convex/auth.ts index 5bcbc32..1233414 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -123,11 +123,7 @@ export const createUserWithProfile = mutation({ return { error: "Invite expired" }; } - if ( - invite.maxUses !== undefined && - invite.maxUses !== null && - invite.uses >= invite.maxUses - ) { + if (invite.maxUses !== undefined && invite.uses >= invite.maxUses) { return { error: "Invite max uses reached" }; } @@ -240,8 +236,12 @@ export const getPublicKeys = query({ }, }); -// Update user profile (aboutMe, avatar, customStatus) -export const updateProfile = mutation({ +// Internal writer. Public entry point: `authActions.updateProfile`. The +// action layer verifies the caller controls `userId` via Ed25519 +// signature before this runs; calling this from another mutation +// without that check would reintroduce the "anyone can change anyone's +// profile" vulnerability. +export const updateProfileInternal = internalMutation({ args: { userId: v.id("userProfiles"), displayName: v.optional(v.string()), @@ -278,8 +278,8 @@ export const getMyJoinSoundUrl = query({ }, }); -// Update user status -export const updateStatus = mutation({ +// Internal writer. Public entry point: `authActions.updateStatus`. +export const updateStatusInternal = internalMutation({ args: { userId: v.id("userProfiles"), status: v.string(), @@ -343,8 +343,51 @@ export const getUserForRecovery = internalQuery({ }, }); -// Set nickname (displayName) for a user -export const setNickname = mutation({ +// Internal: resolve a userId to the fields needed by the voice-token action +// (server-side signature verification + LiveKit identity). +export const getUserForVoiceToken = internalQuery({ + args: { userId: v.id("userProfiles") }, + returns: v.union( + v.object({ + userId: v.id("userProfiles"), + username: v.string(), + publicSigningKey: v.string(), + }), + v.null() + ), + handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); + if (!user) return null; + return { + userId: user._id, + username: user.username, + publicSigningKey: user.publicSigningKey, + }; + }, +}); + +// Internal: fetch a channel for the voice-token action so it can confirm the +// target channel exists and is actually a voice/dm room. +export const getChannelForVoiceToken = internalQuery({ + args: { channelId: v.id("channels") }, + returns: v.union( + v.object({ + channelId: v.id("channels"), + type: v.string(), + }), + v.null() + ), + handler: async (ctx, args) => { + const channel = await ctx.db.get(args.channelId); + if (!channel) return null; + return { channelId: channel._id, type: channel.type }; + }, +}); + +// Internal writer. Public entry point: `authActions.setNickname`. The +// action layer verifies the caller controls `actorUserId` via +// signature; the existing self-or-manage_nicknames gate stays here. +export const setNicknameInternal = internalMutation({ args: { actorUserId: v.id("userProfiles"), targetUserId: v.id("userProfiles"), @@ -371,8 +414,10 @@ export const setNickname = mutation({ }, }); -// Delete a user and all their associated data (admin only) -export const deleteUser = mutation({ +// Internal writer. Public entry point: `authActions.deleteUser`. Both +// the isAdmin check and the destructive delete live here; the action +// layer verifies the caller controls `requestingUserId`. +export const deleteUserInternal = internalMutation({ args: { requestingUserId: v.id("userProfiles"), targetUserId: v.id("userProfiles"), diff --git a/convex/authActions.ts b/convex/authActions.ts new file mode 100644 index 0000000..0b22072 --- /dev/null +++ b/convex/authActions.ts @@ -0,0 +1,109 @@ +"use node"; + +import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { v } from "convex/values"; +import { requireAuth } from "./authGuard"; + +/** + * Signed profile update. The canonical message binds userId + timestamp; + * the 5-minute replay window limits damage if a signature leaks. See + * `authGuard.requireAuth` for the full verification flow. + */ +export const updateProfile = action({ + args: { + userId: v.id("userProfiles"), + displayName: v.optional(v.string()), + aboutMe: v.optional(v.string()), + avatarStorageId: v.optional(v.id("_storage")), + customStatus: v.optional(v.string()), + joinSoundStorageId: v.optional(v.id("_storage")), + removeJoinSound: v.optional(v.boolean()), + accentColor: v.optional(v.string()), + authTimestamp: v.number(), + authSignature: v.string(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const canonical = `updateProfile:${args.userId}:${args.authTimestamp}`; + await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical); + await ctx.runMutation(internal.auth.updateProfileInternal, { + userId: args.userId, + displayName: args.displayName, + aboutMe: args.aboutMe, + avatarStorageId: args.avatarStorageId, + customStatus: args.customStatus, + joinSoundStorageId: args.joinSoundStorageId, + removeJoinSound: args.removeJoinSound, + accentColor: args.accentColor, + }); + return null; + }, +}); + +export const updateStatus = action({ + args: { + userId: v.id("userProfiles"), + status: v.string(), + authTimestamp: v.number(), + authSignature: v.string(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const canonical = `updateStatus:${args.userId}:${args.status}:${args.authTimestamp}`; + await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical); + await ctx.runMutation(internal.auth.updateStatusInternal, { + userId: args.userId, + status: args.status, + }); + return null; + }, +}); + +export const setNickname = action({ + args: { + actorUserId: v.id("userProfiles"), + targetUserId: v.id("userProfiles"), + displayName: v.string(), + authTimestamp: v.number(), + authSignature: v.string(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const canonical = `setNickname:${args.actorUserId}:${args.targetUserId}:${args.authTimestamp}`; + await requireAuth(ctx, args.actorUserId, args.authTimestamp, args.authSignature, canonical); + await ctx.runMutation(internal.auth.setNicknameInternal, { + actorUserId: args.actorUserId, + targetUserId: args.targetUserId, + displayName: args.displayName, + }); + return null; + }, +}); + +export const deleteUser = action({ + args: { + requestingUserId: v.id("userProfiles"), + targetUserId: v.id("userProfiles"), + authTimestamp: v.number(), + authSignature: v.string(), + }, + returns: v.object({ success: v.boolean(), error: v.optional(v.string()) }), + handler: async ( + ctx, + args, + ): Promise<{ success: boolean; error?: string }> => { + const canonical = `deleteUser:${args.requestingUserId}:${args.targetUserId}:${args.authTimestamp}`; + await requireAuth( + ctx, + args.requestingUserId, + args.authTimestamp, + args.authSignature, + canonical, + ); + return await ctx.runMutation(internal.auth.deleteUserInternal, { + requestingUserId: args.requestingUserId, + targetUserId: args.targetUserId, + }); + }, +}); diff --git a/convex/authGuard.ts b/convex/authGuard.ts new file mode 100644 index 0000000..e1a31de --- /dev/null +++ b/convex/authGuard.ts @@ -0,0 +1,63 @@ +"use node"; + +import crypto from "crypto"; +import type { ActionCtx } from "./_generated/server"; +import { internal } from "./_generated/api"; +import type { Id } from "./_generated/dataModel"; + +const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; + +/** + * Detached Ed25519 check: caller must sign `canonicalMessage` with the + * Ed25519 private key that pairs with the userProfile's stored + * `publicSigningKey`. Matches `voice.getToken` and + * `recovery.resetPasswordAction` — same window, same SPKI/PEM encoding, + * same hex signature format — so client-side signing code looks + * identical across every sensitive call. + * + * Throws on failure. Sensitive mutations that previously trusted a raw + * `userId` arg should now route through a "use node" action that calls + * this helper before running the internal mutation. + */ +export async function requireAuth( + ctx: ActionCtx, + userId: Id<"userProfiles">, + timestamp: number, + signature: string, + canonicalMessage: string, +): Promise { + if ( + !Number.isFinite(timestamp) || + Math.abs(Date.now() - timestamp) > MAX_CLOCK_SKEW_MS + ) { + throw new Error("Request expired. Please try again."); + } + + const user = await ctx.runQuery(internal.auth.getUserForVoiceToken, { + userId, + }); + if (!user) { + throw new Error("User not found"); + } + + let isValid = false; + try { + const publicKeyObj = crypto.createPublicKey({ + key: user.publicSigningKey, + format: "pem", + type: "spki", + }); + isValid = crypto.verify( + null, + Buffer.from(canonicalMessage), + publicKeyObj, + Buffer.from(signature, "hex"), + ); + } catch { + throw new Error("Signature verification failed"); + } + + if (!isValid) { + throw new Error("Invalid signature"); + } +} diff --git a/convex/invites.ts b/convex/invites.ts index 1df7ca5..0491d46 100644 --- a/convex/invites.ts +++ b/convex/invites.ts @@ -51,11 +51,7 @@ export const use = query({ return { error: "Invite expired" }; } - if ( - invite.maxUses !== undefined && - invite.maxUses !== null && - invite.uses >= invite.maxUses - ) { + if (invite.maxUses !== undefined && invite.uses >= invite.maxUses) { return { error: "Invite max uses reached" }; } diff --git a/convex/links.ts b/convex/links.ts index 69927dc..51fddf2 100644 --- a/convex/links.ts +++ b/convex/links.ts @@ -11,6 +11,13 @@ export const fetchPreview = action({ description: v.optional(v.string()), image: v.optional(v.string()), siteName: v.optional(v.string()), + // Image dimensions sourced from og:image:width/height or + // twitter:image:width/height when the page emits them. The client + // uses these to reserve the preview card's image slot *before* the + // image decodes, eliminating the height shift that used to expand + // the card on first paint. + imageWidth: v.optional(v.number()), + imageHeight: v.optional(v.number()), }), v.null(), ), @@ -89,6 +96,26 @@ export const fetchPreview = action({ const siteName = pick(/]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i); + const pickNum = (re: RegExp): number | undefined => { + const raw = pick(re); + if (!raw) return undefined; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined; + }; + // og: and twitter: tags both publish the intended image dimensions + // in their own namespaces — use whichever is available. The attr + // order in HTML varies (some sites emit `content="w"` before + // `property=...`), so fall back to a second regex with the + // attributes swapped. + const imageWidth = + pickNum(/]+property=["']og:image:width["'][^>]+content=["']([^"']+)["']/i) ?? + pickNum(/]+content=["']([^"']+)["'][^>]+property=["']og:image:width["']/i) ?? + pickNum(/]+name=["']twitter:image:width["'][^>]+content=["']([^"']+)["']/i); + const imageHeight = + pickNum(/]+property=["']og:image:height["'][^>]+content=["']([^"']+)["']/i) ?? + pickNum(/]+content=["']([^"']+)["'][^>]+property=["']og:image:height["']/i) ?? + pickNum(/]+name=["']twitter:image:height["'][^>]+content=["']([^"']+)["']/i); + // Resolve relative image URLs if (image) { try { @@ -97,7 +124,15 @@ export const fetchPreview = action({ } if (!title && !description && !image) return null; - return { url: u.toString(), title, description, image, siteName }; + return { + url: u.toString(), + title, + description, + image, + siteName, + imageWidth, + imageHeight, + }; } catch { return null; } diff --git a/convex/messageActions.ts b/convex/messageActions.ts new file mode 100644 index 0000000..12f1173 --- /dev/null +++ b/convex/messageActions.ts @@ -0,0 +1,109 @@ +"use node"; + +import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { v } from "convex/values"; +import { requireAuth } from "./authGuard"; + +/** + * Signed-send: the client signs `send:${channelId}:${senderId}:${timestamp}` + * with their Ed25519 key so the server can prove the caller controls + * `senderId`. Prevents the "anyone posts as anyone" bypass that existed + * when `messages.send` was a plain mutation trusting the client-supplied + * `senderId`. + * + * The per-message `signature` over the ciphertext is a separate, + * recipient-verified integrity check and is unchanged. + */ +export const send = action({ + 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")), + authTimestamp: v.number(), + authSignature: v.string(), + }, + returns: v.object({ id: v.id("messages") }), + handler: async (ctx, args): Promise<{ id: any }> => { + const canonical = `send:${args.channelId}:${args.senderId}:${args.authTimestamp}`; + await requireAuth(ctx, args.senderId, args.authTimestamp, args.authSignature, canonical); + return await ctx.runMutation(internal.messages.sendInternal, { + channelId: args.channelId, + senderId: args.senderId, + ciphertext: args.ciphertext, + nonce: args.nonce, + signature: args.signature, + keyVersion: args.keyVersion, + replyTo: args.replyTo, + }); + }, +}); + +export const edit = action({ + args: { + id: v.id("messages"), + userId: v.id("userProfiles"), + ciphertext: v.string(), + nonce: v.string(), + signature: v.string(), + authTimestamp: v.number(), + authSignature: v.string(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const canonical = `edit:${args.id}:${args.userId}:${args.authTimestamp}`; + await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical); + await ctx.runMutation(internal.messages.editInternal, { + id: args.id, + userId: args.userId, + ciphertext: args.ciphertext, + nonce: args.nonce, + signature: args.signature, + }); + return null; + }, +}); + +export const pin = action({ + args: { + id: v.id("messages"), + userId: v.id("userProfiles"), + pinned: v.boolean(), + authTimestamp: v.number(), + authSignature: v.string(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const canonical = `pin:${args.id}:${args.userId}:${args.pinned}:${args.authTimestamp}`; + await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical); + await ctx.runMutation(internal.messages.pinInternal, { + id: args.id, + userId: args.userId, + pinned: args.pinned, + }); + return null; + }, +}); + +export const remove = action({ + args: { + id: v.id("messages"), + userId: v.id("userProfiles"), + authTimestamp: v.number(), + authSignature: v.string(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const canonical = `remove:${args.id}:${args.userId}:${args.authTimestamp}`; + await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical); + await ctx.runMutation(internal.messages.removeInternal, { + id: args.id, + userId: args.userId, + }); + return null; + }, +}); diff --git a/convex/messages.ts b/convex/messages.ts index 35eb1a5..a014bc3 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -1,4 +1,4 @@ -import { query, mutation } from "./_generated/server"; +import { query, internalMutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; import { v } from "convex/values"; import { getPublicStorageUrl } from "./storageUrl"; @@ -225,7 +225,19 @@ export const searchScan = query({ }, }); -export const send = mutation({ +// Plain text caps out at 4000 chars in the composer, which encodes to +// well under 8KB of AES-GCM output. Attachments ride a separate +// metadata-only JSON path, so 64KB leaves ~8× headroom for future +// structured content without letting a rogue client write multi-MB +// rows and bloat the database. +const MAX_CIPHERTEXT_CHARS = 64 * 1024; + +// Internal write. The public API is `messageActions.send`, which verifies +// the caller's Ed25519 signature over (channelId, senderId, timestamp) +// before invoking this. Never call this from a public mutation — it +// trusts `senderId` completely and doing so would reintroduce the +// spoofing vulnerability. +export const sendInternal = internalMutation({ args: { channelId: v.id("channels"), senderId: v.id("userProfiles"), @@ -237,6 +249,9 @@ export const send = mutation({ }, returns: v.object({ id: v.id("messages") }), handler: async (ctx, args) => { + if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) { + throw new Error("Message too large"); + } const id = await ctx.db.insert("messages", { channelId: args.channelId, senderId: args.senderId, @@ -250,7 +265,11 @@ export const send = mutation({ }, }); -export const sendBatch = mutation({ +// Internal-only: there's no public caller and no way to verify every +// senderId in a batch without a signature per message, which defeats +// the point of batching. Kept as internalMutation so internal seed +// scripts / migrations can still use it. +export const sendBatchInternal = internalMutation({ args: { messages: v.array(v.object({ channelId: v.id("channels"), @@ -270,15 +289,24 @@ export const sendBatch = mutation({ }, }); -export const edit = mutation({ +// Internal write — public entry point is `messageActions.edit`. +// Enforces that only the original sender can edit. The action layer +// has already verified the caller controls `userId`. +export const editInternal = internalMutation({ args: { id: v.id("messages"), + userId: v.id("userProfiles"), ciphertext: v.string(), nonce: v.string(), signature: v.string(), }, returns: v.null(), handler: async (ctx, args) => { + const msg = await ctx.db.get(args.id); + if (!msg) throw new Error("Message not found"); + if (msg.senderId !== args.userId) { + throw new Error("Only the author can edit this message"); + } await ctx.db.patch(args.id, { ciphertext: args.ciphertext, nonce: args.nonce, @@ -289,13 +317,24 @@ export const edit = mutation({ }, }); -export const pin = mutation({ +// Internal write — public entry point is `messageActions.pin`. Only +// `manage_messages` role-holders can pin/unpin; the action layer has +// already verified the caller controls `userId`. +export const pinInternal = internalMutation({ args: { id: v.id("messages"), + userId: v.id("userProfiles"), pinned: v.boolean(), }, returns: v.null(), handler: async (ctx, args) => { + const roles = await getRolesForUser(ctx, args.userId); + const canManage = roles.some( + (role) => (role.permissions as Record)?.manage_messages, + ); + if (!canManage) { + throw new Error("Not authorized to pin messages"); + } await ctx.db.patch(args.id, { pinned: args.pinned }); return null; }, @@ -460,7 +499,11 @@ export const listAfter = query({ }, }); -export const remove = mutation({ +// Internal write — public entry point is `messageActions.remove`. The +// existing isSender-or-manage_messages check stays here; the action +// layer verifies the caller actually controls `userId` before we trust +// that arg. +export const removeInternal = internalMutation({ args: { id: v.id("messages"), userId: v.id("userProfiles") }, returns: v.null(), handler: async (ctx, args) => { diff --git a/convex/schema.ts b/convex/schema.ts index 23eccc5..721bf25 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -107,7 +107,8 @@ export default defineSchema({ username: v.string(), expiresAt: v.number(), // timestamp }).index("by_channel", ["channelId"]) - .index("by_user", ["userId"]), + .index("by_user", ["userId"]) + .index("by_expires_at", ["expiresAt"]), voiceStates: defineTable({ channelId: v.id("channels"), diff --git a/convex/typing.ts b/convex/typing.ts index 64b0d4a..05e94d7 100644 --- a/convex/typing.ts +++ b/convex/typing.ts @@ -22,6 +22,11 @@ export const startTyping = mutation({ const userTyping = existing.find((t) => t.userId === args.userId); if (userTyping) { + // Refreshing an existing row — the cleanup scheduled from the + // original insert is still pending, so don't pile another copy + // onto the scheduler. The audit flagged the old code (schedule + // on every heartbeat) as spamming cleanExpired tasks that all + // did the same work. await ctx.db.patch(userTyping._id, { expiresAt }); } else { await ctx.db.insert("typingIndicators", { @@ -30,9 +35,9 @@ export const startTyping = mutation({ username: args.username, expiresAt, }); + await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {}); } - await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {}); return null; }, }); @@ -93,11 +98,15 @@ export const cleanExpired = internalMutation({ returns: v.null(), handler: async (ctx) => { const now = Date.now(); - const expired = await ctx.db.query("typingIndicators").collect(); + // Range-scan the by_expires_at index instead of collecting the whole + // table, so cleanup cost scales with number of *expired* rows rather + // than total typing activity across every channel. + const expired = await ctx.db + .query("typingIndicators") + .withIndex("by_expires_at", (q) => q.lte("expiresAt", now)) + .collect(); for (const t of expired) { - if (t.expiresAt <= now) { - await ctx.db.delete(t._id); - } + await ctx.db.delete(t._id); } return null; }, diff --git a/convex/voice.ts b/convex/voice.ts index b6d9f4d..fb330d8 100644 --- a/convex/voice.ts +++ b/convex/voice.ts @@ -1,12 +1,48 @@ "use node"; import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; import { v } from "convex/values"; import { AccessToken, RoomServiceClient } from "livekit-server-sdk"; +import crypto from "crypto"; +import type { Id } from "./_generated/dataModel"; + +// Shapes of the internal queries below, spelled out so voice.ts can typecheck +// without depending on `internal.auth.*` inference — otherwise the codegen +// cycle (voice exports -> _generated/api -> internal.auth typing -> voice +// usage) drops back to `any` and TS7022/TS7023 fire on the whole action. +type VoiceTokenUser = { + userId: Id<"userProfiles">; + username: string; + publicSigningKey: string; +}; +type VoiceTokenChannel = { + channelId: Id<"channels">; + type: string; +}; +type VoiceTokenResult = { token: string } | { error: string }; + +// Signed message the client must sign with their Ed25519 private signing key +// to prove they control the userId they're asking a token for. `timestamp` +// defeats replay; the channelId binds the signature to a specific room. +function buildVoiceTokenMessage( + userId: string, + channelId: string, + timestamp: number, +): string { + return `voice-token:${userId}:${channelId}:${timestamp}`; +} /** * Generate a LiveKit join token for a voice channel. * + * Authorization: caller signs `voice-token:userId:channelId:timestamp` with + * their Ed25519 signing key. The server verifies against the userProfile's + * `publicSigningKey`, rejects stale timestamps, and confirms the channel + * exists and is a voice/dm room. The LiveKit identity is pinned to the + * server-resolved user so clients can't impersonate each other even with a + * valid signature for their own account. + * * LiveKit servers run with `room.auto_create: false` reject joins for * rooms that don't already exist — the client gets a 404 "requested * room does not exist" back from the /rtc/v1/validate endpoint. To @@ -22,12 +58,65 @@ import { AccessToken, RoomServiceClient } from "livekit-server-sdk"; */ export const getToken = action({ args: { - channelId: v.string(), - userId: v.string(), - username: v.string(), + channelId: v.id("channels"), + userId: v.id("userProfiles"), + timestamp: v.number(), + signature: v.string(), }, - returns: v.object({ token: v.string() }), - handler: async (_ctx, args) => { + returns: v.union( + v.object({ token: v.string() }), + v.object({ error: v.string() }), + ), + handler: async (ctx, args): Promise => { + // Reject timestamps outside a 5-minute window on either side of the + // server clock. Matches the recovery action's window and keeps replay + // attempts short-lived even if a signature leaks. + const now = Date.now(); + if (!Number.isFinite(args.timestamp) || Math.abs(now - args.timestamp) > 5 * 60 * 1000) { + return { error: "Request expired. Please try again." }; + } + + const user: VoiceTokenUser | null = await ctx.runQuery( + internal.auth.getUserForVoiceToken, + { userId: args.userId }, + ); + if (!user) { + return { error: "User not found" }; + } + + const channel: VoiceTokenChannel | null = await ctx.runQuery( + internal.auth.getChannelForVoiceToken, + { channelId: args.channelId }, + ); + if (!channel) { + return { error: "Channel not found" }; + } + if (channel.type !== "voice" && channel.type !== "dm") { + return { error: "Not a voice channel" }; + } + + // Verify the caller actually controls `userId` by checking their + // signature over (userId, channelId, timestamp). + const message = buildVoiceTokenMessage(user.userId, channel.channelId, args.timestamp); + try { + const publicKeyObj = crypto.createPublicKey({ + key: user.publicSigningKey, + format: "pem", + type: "spki", + }); + const isValid = crypto.verify( + null, + Buffer.from(message), + publicKeyObj, + Buffer.from(args.signature, "hex"), + ); + if (!isValid) { + return { error: "Invalid signature" }; + } + } catch { + return { error: "Signature verification failed" }; + } + const apiKey = process.env.LIVEKIT_API_KEY || "devkey"; const apiSecret = process.env.LIVEKIT_API_SECRET || "secret"; const livekitUrl = @@ -43,7 +132,7 @@ export const getToken = action({ try { const roomService = new RoomServiceClient(httpUrl, apiKey, apiSecret); await roomService.createRoom({ - name: args.channelId, + name: channel.channelId, // Empty rooms auto-destroy after 5 minutes with no participants, // matching LiveKit's own default so stale rooms from a crashed // client don't pile up forever. @@ -56,36 +145,39 @@ export const getToken = action({ } catch (err: any) { // 409 / "already exists" is expected when a room has already // been created by an earlier join — swallow it and continue. - const message = String(err?.message ?? err ?? ""); + const errMsg = String(err?.message ?? err ?? ""); const status = err?.status ?? err?.statusCode; const alreadyExists = status === 409 || - /already exists/i.test(message) || - /AlreadyExists/i.test(message); + /already exists/i.test(errMsg) || + /AlreadyExists/i.test(errMsg); if (!alreadyExists) { // Non-fatal: log and fall through to token generation. If the // real issue was misconfiguration the client will surface the // 404 it already does. - console.warn("LiveKit createRoom failed:", message); + console.warn("LiveKit createRoom failed:", errMsg); } } } - const at = new AccessToken(apiKey, apiSecret, { - identity: args.userId, - name: args.username, + // Pin identity + name to the server-resolved user. A forged `username` + // or `userId` in the args would have already been rejected by the + // signature check, but using the DB values is defence-in-depth. + const at: AccessToken = new AccessToken(apiKey, apiSecret, { + identity: user.userId, + name: user.username, ttl: "24h", }); at.addGrant({ roomJoin: true, - room: args.channelId, + room: channel.channelId, canPublish: true, canSubscribe: true, canPublishData: true, }); - const token = await at.toJwt(); + const token: string = await at.toJwt(); return { token }; }, }); diff --git a/packages/platform-web/src/idle.js b/packages/platform-web/src/idle.js index d06cb53..5940892 100644 --- a/packages/platform-web/src/idle.js +++ b/packages/platform-web/src/idle.js @@ -1,36 +1,121 @@ /** - * Web platform idle detection using Page Visibility API. - * Provides a simplified version of the Electron idle API. + * Web platform idle detection. + * + * Prefers the Idle Detection API (Chromium-only, requires user + * permission) which reports actual system-level idle, so a Discord tab + * in the background doesn't auto-AFK users who are actively using their + * computer. Falls back to input-event tracking + Page Visibility when + * IdleDetector is unavailable (Firefox, Safari) or permission is denied. + * + * The input-event fallback tracks mouse/keyboard/touch/scroll on the + * page and resets an activity timestamp. It only measures idle *while + * the tab has been active at some point* — it's a proxy, not a true OS + * idle signal — but it's strictly better than the old Page-Visibility + * approach which treated every backgrounded tab as idle even if the + * user was typing in another window. */ + +const ACTIVITY_EVENTS = [ + 'mousemove', + 'mousedown', + 'keydown', + 'touchstart', + 'scroll', + 'wheel', + 'pointerdown', + 'focus', +]; + let idleCallback = null; let lastActiveTime = Date.now(); +let idleDetector = null; +let idleDetectorAbort = null; +// Guard against stacked listeners when onIdleStateChanged fires twice +// without a cleanup in between (StrictMode double-invoke, hot reload, +// or a stale consumer). Without this flag, every activity event would +// call the callback N times — spotted during the bug audit. +let listenersAttached = false; + +function onActivity() { + lastActiveTime = Date.now(); + if (idleCallback) idleCallback({ isIdle: false }); +} + function handleVisibilityChange() { - if (!idleCallback) return; - if (document.hidden) { - idleCallback({ isIdle: true }); - } else { + if (!document.hidden) { lastActiveTime = Date.now(); - idleCallback({ isIdle: false }); + if (idleCallback) idleCallback({ isIdle: false }); + } +} + +function attachFallbackListeners() { + if (listenersAttached) return; + for (const ev of ACTIVITY_EVENTS) { + window.addEventListener(ev, onActivity, { passive: true, capture: true }); + } + document.addEventListener('visibilitychange', handleVisibilityChange); + listenersAttached = true; +} + +function detachFallbackListeners() { + if (!listenersAttached) return; + for (const ev of ACTIVITY_EVENTS) { + window.removeEventListener(ev, onActivity, { capture: true }); + } + document.removeEventListener('visibilitychange', handleVisibilityChange); + listenersAttached = false; +} + +async function tryStartIdleDetector() { + // IdleDetector is Chromium-only and gated behind the `idle-detection` + // permission. Fail silently if the API is missing or permission is + // denied — the fallback listeners will still run. + if (typeof window === 'undefined' || !('IdleDetector' in window)) return false; + try { + const state = await window.IdleDetector.requestPermission(); + if (state !== 'granted') return false; + idleDetectorAbort = new AbortController(); + idleDetector = new window.IdleDetector(); + idleDetector.addEventListener('change', () => { + const isIdle = + idleDetector.userState === 'idle' || + idleDetector.screenState === 'locked'; + if (!isIdle) lastActiveTime = Date.now(); + if (idleCallback) idleCallback({ isIdle }); + }); + // Threshold must be >= 60s per spec. + await idleDetector.start({ threshold: 60_000, signal: idleDetectorAbort.signal }); + return true; + } catch { + idleDetector = null; + idleDetectorAbort = null; + return false; } } export default { getSystemIdleTime() { - // Return seconds since last activity (approximation using visibility) - if (document.hidden) { - return Math.floor((Date.now() - lastActiveTime) / 1000); - } - return 0; + return Math.floor((Date.now() - lastActiveTime) / 1000); }, onIdleStateChanged(callback) { idleCallback = callback; - document.addEventListener('visibilitychange', handleVisibilityChange); + attachFallbackListeners(); + void tryStartIdleDetector(); }, removeIdleStateListener() { idleCallback = null; - document.removeEventListener('visibilitychange', handleVisibilityChange); + detachFallbackListeners(); + if (idleDetectorAbort) { + try { + idleDetectorAbort.abort(); + } catch { + /* already aborted */ + } + idleDetectorAbort = null; + } + idleDetector = null; }, }; diff --git a/packages/platform-web/src/session.js b/packages/platform-web/src/session.js index f5e3de7..d7cc47d 100644 --- a/packages/platform-web/src/session.js +++ b/packages/platform-web/src/session.js @@ -4,12 +4,34 @@ */ const SESSION_KEY = 'discord-clone-session'; +function isQuotaError(e) { + if (!e) return false; + const name = e.name || ''; + const code = e.code; + return ( + name === 'QuotaExceededError' || + name === 'NS_ERROR_DOM_QUOTA_REACHED' || + code === 22 || + code === 1014 + ); +} + export default { save(data) { try { localStorage.setItem(SESSION_KEY, JSON.stringify(data)); return Promise.resolve(true); - } catch { + } catch (e) { + if (isQuotaError(e)) { + // Reject instead of quietly returning `false` — a quota failure + // here means encryption keys never made it to disk, so the user + // will be logged out on next reload. The caller needs to know. + const err = new Error('Browser storage quota exceeded'); + err.isQuotaError = true; + return Promise.reject(err); + } + // Non-quota serialization failures are still surfaced via `false` + // to preserve the existing API contract for Electron parity. return Promise.resolve(false); } }, diff --git a/packages/platform-web/src/settings.js b/packages/platform-web/src/settings.js index f1c1211..f87908f 100644 --- a/packages/platform-web/src/settings.js +++ b/packages/platform-web/src/settings.js @@ -4,12 +4,29 @@ */ const PREFIX = 'discord-clone-settings:'; +// Browsers spell the quota error in a few different ways across vendors. +// Normalizing here so callers can `if (err.isQuotaError)` regardless. +function isQuotaError(e) { + if (!e) return false; + const name = e.name || ''; + const code = e.code; + return ( + name === 'QuotaExceededError' || + name === 'NS_ERROR_DOM_QUOTA_REACHED' || + code === 22 || + code === 1014 + ); +} + export default { get(key) { try { const raw = localStorage.getItem(PREFIX + key); return Promise.resolve(raw !== null ? JSON.parse(raw) : undefined); } catch { + // Corrupted JSON or blocked storage access — return undefined so + // the caller falls back to defaults. Worth recovering silently + // here because a single bad key shouldn't take down the app. return Promise.resolve(undefined); } }, @@ -18,8 +35,17 @@ export default { try { localStorage.setItem(PREFIX + key, JSON.stringify(value)); return Promise.resolve(); - } catch { - return Promise.resolve(); + } catch (e) { + if (isQuotaError(e)) { + // Reject so the caller can surface the quota error to the user — + // silently swallowing meant settings just "didn't save" with no + // warning. Decorate with a flag so callers can branch without + // sniffing error names themselves. + const err = new Error('Browser storage quota exceeded'); + err.isQuotaError = true; + return Promise.reject(err); + } + return Promise.reject(e); } }, }; diff --git a/packages/shared/package.json b/packages/shared/package.json index 5e3232e..db2e95d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/shared", "private": true, - "version": "1.0.90", + "version": "1.1.00", "type": "module", "main": "src/App.tsx", "dependencies": { diff --git a/packages/shared/src/components/auth/LoginPage.tsx b/packages/shared/src/components/auth/LoginPage.tsx index 956bd84..0e97df5 100644 --- a/packages/shared/src/components/auth/LoginPage.tsx +++ b/packages/shared/src/components/auth/LoginPage.tsx @@ -66,8 +66,18 @@ export function LoginPage() { searchDbKey: searchKeys.dak, savedAt: Date.now(), }); - } catch (err) { - console.warn('Session persistence unavailable:', err); + } catch (err: any) { + // Quota overflow means encryption keys never hit disk — + // user will be logged out on next reload. Surface this + // so they can clear browser storage instead of + // wondering why they keep getting kicked out. + if (err?.isQuotaError) { + setError( + 'Browser storage is full. You will be logged out on reload. Free up storage to persist your session.', + ); + } else { + console.warn('Session persistence unavailable:', err); + } } } diff --git a/packages/shared/src/components/channel/ChannelTextarea.tsx b/packages/shared/src/components/channel/ChannelTextarea.tsx index 05034fe..d36573c 100644 --- a/packages/shared/src/components/channel/ChannelTextarea.tsx +++ b/packages/shared/src/components/channel/ChannelTextarea.tsx @@ -1,4 +1,4 @@ -import { useConvex, useMutation, useQuery } from 'convex/react'; +import { useAction, useConvex, useMutation, useQuery } from 'convex/react'; import { ArrowUp, ChartBar, @@ -130,7 +130,7 @@ export function ChannelTextarea({ ); const keyBundle = allKeys?.find((k) => k.channel_id === channelId) ?? null; - const sendMessage = useMutation(api.messages.send); + const sendMessage = useAction(api.messageActions.send); const generateUploadUrl = useMutation(api.files.generateUploadUrl); const validateUpload = useMutation(api.files.validateUpload); @@ -389,6 +389,15 @@ export function ChannelTextarea({ .map((a) => a.previewUrl) .filter((u): u is string => !!u); + // Clear the composer up front so a second Enter keystroke + // (while the signed send round-trips through the action) can't + // re-submit the same text. Snapshot innerHTML first so we can + // put the draft back if the send throws. + const prevHTML = editorRef.current?.innerHTML ?? ''; + if (editorRef.current) editorRef.current.textContent = ''; + setIsEmpty(true); + setMentionQuery(null); + try { // 1. Text message first (if any). Matches the old client's // send-then-attach order so the reply context lands on @@ -397,6 +406,11 @@ export function ChannelTextarea({ const { content, iv, tag } = await crypto.encryptData(text, channelKey); const ciphertext = content + tag; const signature = await crypto.signMessage(signingKey, ciphertext); + const authTimestamp = Date.now(); + const authSignature = await crypto.signMessage( + signingKey, + `send:${channelId}:${userId}:${authTimestamp}`, + ); await sendMessage({ channelId: channelId as any, senderId: userId as any, @@ -405,6 +419,8 @@ export function ChannelTextarea({ signature, keyVersion: channelKeyVersion, replyTo: replyTo ? (replyTo.eventId as any) : undefined, + authTimestamp, + authSignature, }); } @@ -430,9 +446,6 @@ export function ChannelTextarea({ } } - if (editorRef.current) editorRef.current.textContent = ''; - setIsEmpty(true); - setMentionQuery(null); if (userId && channelId) { void stopTyping({ channelId: channelId as any, @@ -443,6 +456,13 @@ export function ChannelTextarea({ onCancelReply?.(); } catch (err) { console.error('Failed to send message:', err); + // Send failed — restore the draft so the user can retry + // without retyping. innerHTML preserves mentions, emoji + // nodes, and any other rich content. + if (editorRef.current && prevHTML) { + editorRef.current.innerHTML = prevHTML; + setIsEmpty((editorRef.current.textContent ?? '').trim().length === 0); + } } }; @@ -455,6 +475,11 @@ export function ChannelTextarea({ const { content, iv, tag } = await crypto.encryptData(payload, channelKey); const ciphertext = content + tag; const signature = await crypto.signMessage(signingKey, ciphertext); + const authTimestamp = Date.now(); + const authSignature = await crypto.signMessage( + signingKey, + `send:${channelId}:${userId}:${authTimestamp}`, + ); await sendMessage({ channelId: channelId as any, senderId: userId as any, @@ -463,6 +488,8 @@ export function ChannelTextarea({ signature, keyVersion: channelKeyVersion, replyTo: replyTo ? (replyTo.eventId as any) : undefined, + authTimestamp, + authSignature, }); }; @@ -476,6 +503,11 @@ export function ChannelTextarea({ const { content, iv, tag } = await crypto.encryptData(payload, channelKey); const ciphertext = content + tag; const signature = await crypto.signMessage(signingKey, ciphertext); + const authTimestamp = Date.now(); + const authSignature = await crypto.signMessage( + signingKey, + `send:${channelId}:${userId}:${authTimestamp}`, + ); await sendMessage({ channelId: channelId as any, senderId: userId as any, @@ -484,6 +516,8 @@ export function ChannelTextarea({ signature, keyVersion: keyBundle?.key_version ?? 1, replyTo: replyTo ? (replyTo.eventId as any) : undefined, + authTimestamp, + authSignature, }); }; diff --git a/packages/shared/src/components/channel/DeleteConfirmationModal.module.css b/packages/shared/src/components/channel/DeleteConfirmationModal.module.css new file mode 100644 index 0000000..a44e699 --- /dev/null +++ b/packages/shared/src/components/channel/DeleteConfirmationModal.module.css @@ -0,0 +1,99 @@ +/* ── Delete confirmation modal ──────────────────────────────────── + Shown before `api.messageActions.remove` actually runs. Parallels + PinConfirmationModal: description + static PinnedMessageRow + preview + Cancel / Delete actions. Two differences from the pin + dialog: the action row is horizontal (Cancel left, Delete right) + and the primary button is always the danger variant. */ + +.body { + display: flex; + flex-direction: column; + gap: 16px; + padding: 0; +} + +.headerFlush { + border-bottom: none; +} + +.description { + font-size: 0.9375rem; + line-height: 1.4; + color: var(--text-secondary); + margin: 0; +} + +.previewWrap { + margin: 0 -12px; +} + +.actions { + display: flex; + flex-direction: row; + gap: 8px; + margin-top: 4px; +} + +.primaryButton, +.secondaryButton { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 12px 16px; + border: none; + border-radius: 0.75rem; + font: inherit; + font-size: 0.9375rem; + font-weight: 700; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.primaryButton { + background-color: var(--brand-primary); + color: #fff; + transition: filter 0.15s; +} + +.primaryButton:active:not(:disabled) { + filter: brightness(0.92); +} + +.primaryButton:disabled { + opacity: 0.55; + cursor: default; +} + +/* Danger variant — Delete is always destructive so this is always on. */ +.primaryButtonDanger { + background-color: var(--button-danger-fill); +} + +.primaryButtonDanger:hover:not(:disabled) { + filter: brightness(1.05); +} + +.primaryButtonDanger:active:not(:disabled) { + background-color: var(--button-danger-active-fill); + filter: none; +} + +.secondaryButton { + background-color: var(--background-secondary-alt); + color: var(--text-primary); + transition: background-color 0.15s; +} + +.secondaryButton:hover, +.secondaryButton:active { + background-color: var(--background-modifier-hover); +} + +.error { + padding: 10px 14px; + background-color: hsl(0, calc(60% * var(--saturation-factor)), 22%); + color: hsl(0, calc(80% * var(--saturation-factor)), 85%); + border-radius: 0.5rem; + font-size: 0.8125rem; +} diff --git a/packages/shared/src/components/channel/DeleteConfirmationModal.tsx b/packages/shared/src/components/channel/DeleteConfirmationModal.tsx new file mode 100644 index 0000000..8453c27 --- /dev/null +++ b/packages/shared/src/components/channel/DeleteConfirmationModal.tsx @@ -0,0 +1,116 @@ +/** + * DeleteConfirmationModal — confirmation dialog shown before a + * message is actually removed. Mirrors PinConfirmationModal: read-only + * `PinnedMessageRow` preview of the target, short reassurance copy, + * two stacked actions with the primary button in danger red. + * + * Calls `api.messageActions.remove` directly (with the signed auth + * payload the action layer requires) so callers don't need to plumb + * their own signing logic through to the confirm button. + */ +import { useState } from 'react'; +import { useAction } from 'convex/react'; +import { Modal } from '@discord-clone/ui'; +import { api } from '../../../../../convex/_generated/api'; +import type { Id } from '../../../../../convex/_generated/dataModel'; +import { usePlatform } from '../../platform'; +import { PinnedMessageRow, type PinnedMessage } from './PinnedMessageRow'; +import styles from './DeleteConfirmationModal.module.css'; + +interface DeleteConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + messageId: string | null; + message: PinnedMessage | null; +} + +export function DeleteConfirmationModal({ + isOpen, + onClose, + messageId, + message, +}: DeleteConfirmationModalProps) { + const removeMessage = useAction(api.messageActions.remove); + const { crypto } = usePlatform(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleConfirm = async () => { + if (!messageId || busy) return; + const userId = + typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; + const signingKey = + typeof sessionStorage !== 'undefined' + ? sessionStorage.getItem('signingKey') + : null; + if (!userId || !signingKey) { + setError('Not signed in'); + return; + } + setBusy(true); + setError(null); + try { + const authTimestamp = Date.now(); + const authSignature = await crypto.signMessage( + signingKey, + `remove:${messageId}:${userId}:${authTimestamp}`, + ); + await removeMessage({ + id: messageId as Id<'messages'>, + userId: userId as Id<'userProfiles'>, + authTimestamp, + authSignature, + }); + onClose(); + } catch (err: any) { + setError(err?.message || 'Failed to delete message.'); + } finally { + setBusy(false); + } + }; + + return ( + + + +
+

+ Are you sure you want to delete this message? This action cannot + be undone. +

+ + {message && ( +
+ +
+ )} + + {error &&
{error}
} + +
+ + +
+
+
+
+ ); +} diff --git a/packages/shared/src/components/channel/EncryptedAttachment.tsx b/packages/shared/src/components/channel/EncryptedAttachment.tsx index 0b1c8a7..9266883 100644 --- a/packages/shared/src/components/channel/EncryptedAttachment.tsx +++ b/packages/shared/src/components/channel/EncryptedAttachment.tsx @@ -23,6 +23,20 @@ const TAG_HEX_LEN = 32; // same file. Keyed by the remote storage URL. const attachmentCache = new Map(); +// Natural dimensions learned on first render for legacy images whose +// metadata predates the upload-time dimension capture. Once populated, +// remounts of the same attachment (pagination, channel re-open) go +// straight to the correctly-sized placeholder instead of the +// fluid-but-wrong 4:3 fallback that used to expand mid-load. +const probedDimsCache = new Map(); + +// Placeholder box for images whose dimensions we haven't probed yet. +// Fixed size is preferable to a fluid fallback because a wrong fluid +// aspect-ratio (e.g. 4:3 for a 16:9 landscape) shifts height when the +// real image lands; a fixed-size box may leave blank margin but doesn't +// shift the scroll anchor. +const PROBE_FALLBACK = { w: 300, h: 200 } as const; + function fromHexString(hex: string): Uint8Array { const matches = hex.match(/.{1,2}/g) ?? []; return new Uint8Array(matches.map((b) => parseInt(b, 16))); @@ -118,24 +132,26 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac } if (kind === 'image') { - // Reserve the exact final layout box up-front so the loaded - // image lands in the same slot the placeholder occupied — no - // post-load height shift, no scroll jump. When the metadata - // carries both width + height we compute the box from them; - // otherwise we fall back to a ratio-aware aspect-ratio so the - // browser still reserves a sensible chunk of space. - const hasDims = !!metadata.width && !!metadata.height; - const maxW = metadata.width ? Math.min(metadata.width, 400) : 300; - const renderedH = - hasDims - ? Math.round(maxW * (metadata.height! / metadata.width!)) - : undefined; + // Dimension resolution order: + // 1. metadata.width/height (captured at upload time — modern path) + // 2. probedDimsCache for this url (learned on a previous mount) + // 3. PROBE_FALLBACK (fixed 300×200) while we wait for the + // first successful onLoad to populate the cache + // Fixed fallback (vs. a fluid 4:3 aspect-ratio) is the critical + // bit: a wrong fluid ratio shifts height when the real image + // lands, which defeats the whole point of reserving space. + const metaDims = + metadata.width && metadata.height + ? { w: metadata.width, h: metadata.height } + : null; + const probedDims = metaDims ?? probedDimsCache.get(metadata.url) ?? null; + const boxDims = probedDims ?? PROBE_FALLBACK; + const maxW = Math.min(boxDims.w, 400); + const renderedH = Math.round(maxW * (boxDims.h / boxDims.w)); const sharedBoxStyle: React.CSSProperties = { width: maxW, - ...(renderedH !== undefined ? { height: renderedH } : {}), - ...(hasDims - ? { aspectRatio: `${metadata.width} / ${metadata.height}` } - : { aspectRatio: '4 / 3' }), + height: renderedH, + aspectRatio: `${boxDims.w} / ${boxDims.h}`, maxHeight: '50vh', borderRadius: 'var(--radius-lg)', }; @@ -165,7 +181,20 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac objectFit: 'cover', cursor: 'pointer', }} - onLoad={() => { + onLoad={(e) => { + // Learn natural dimensions for legacy attachments + // whose metadata omitted width/height. The cache is + // keyed by the remote storage url so re-mounts and + // channel re-opens pick up the correct box without + // re-probing. + if (!metaDims) { + const img = e.currentTarget; + const w = img.naturalWidth; + const h = img.naturalHeight; + if (w > 0 && h > 0) { + probedDimsCache.set(metadata.url, { w, h }); + } + } // Tell the Messages scroller that an attachment // finished decoding so it can re-pin to bottom if // the user is still anchored there. Belt-and- diff --git a/packages/shared/src/components/channel/GifPicker.tsx b/packages/shared/src/components/channel/GifPicker.tsx index 63bf260..e0a7162 100644 --- a/packages/shared/src/components/channel/GifPicker.tsx +++ b/packages/shared/src/components/channel/GifPicker.tsx @@ -113,26 +113,35 @@ export function GifPicker({ onSelectGif }: GifPickerProps) { }, [trendingAction, categoriesAction]); // Debounced search — fires 350ms after the last keystroke so we - // don't hammer the upstream API on every character. + // don't hammer the upstream API on every character. The `cancelled` + // flag is checked after every await so a slow response from an + // earlier query can't overwrite results from a newer one. useEffect(() => { const q = search.trim(); if (!q) { setSearchResults([]); return; } + let cancelled = false; const t = window.setTimeout(async () => { + if (cancelled) return; setLoading(true); setError(null); try { const res: any = await searchAction({ q, limit: 24 }); + if (cancelled) return; setSearchResults(res?.results ?? []); } catch (err: any) { + if (cancelled) return; setError(err?.message ?? 'Search failed.'); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }, 350); - return () => window.clearTimeout(t); + return () => { + cancelled = true; + window.clearTimeout(t); + }; }, [search, searchAction]); const handlePick = (gif: GifResult) => { diff --git a/packages/shared/src/components/channel/LinkEmbed.tsx b/packages/shared/src/components/channel/LinkEmbed.tsx index 0cf62d9..ac58221 100644 --- a/packages/shared/src/components/channel/LinkEmbed.tsx +++ b/packages/shared/src/components/channel/LinkEmbed.tsx @@ -11,6 +11,8 @@ interface UrlPreview { description?: string; imageUrl?: string; siteName?: string; + imageWidth?: number; + imageHeight?: number; } const VIDEO_HOSTS = [ @@ -51,6 +53,23 @@ function isVideoUrl(url: string): boolean { // avoid hammering the fetcher for URLs that will never resolve. const previewCache = new Map(); +// Natural dimensions learned on first successful onLoad for every +// embed image (OG preview image, direct image URL). Populates the +// reserved-box aspect-ratio so the next mount of the same URL goes +// straight to its real proportions instead of the fixed fallback. +const embedImageDimsCache = new Map(); + +// Fallback box for an embed image whose dimensions we haven't probed +// yet. Roughly matches the common OG-image aspect ratio (~1.91:1 for +// Twitter/Facebook card images). Fixed-size fallback > fluid fallback +// because a wrong fluid ratio shifts height when the real image lands. +const EMBED_IMG_FALLBACK = { w: 400, h: 210 } as const; + +// Direct inline video embeds default to 16:9 — most web video ships at +// that ratio. A wrong default just means a little blank space above or +// below the video, not a scroll jump. +const DIRECT_VIDEO_FALLBACK_RATIO = '16 / 9'; + function normaliseMetadata(raw: any): UrlPreview | null { if (!raw || typeof raw !== 'object') return null; @@ -65,9 +84,29 @@ function normaliseMetadata(raw: any): UrlPreview | null { raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined; const siteName = raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined; + // Dimensions come from the Convex action's `imageWidth`/`imageHeight` + // fields (parsed from og:image:width / og:image:height on the server). + // Fall through a few other naming conventions in case a platform- + // native fetcher emits the OG keys verbatim. + const pickNum = (v: unknown): number | undefined => { + if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v; + if (typeof v === 'string') { + const n = Number(v); + if (Number.isFinite(n) && n > 0) return n; + } + return undefined; + }; + const imageWidth = + pickNum(raw.imageWidth) ?? + pickNum(raw['og:image:width']) ?? + pickNum(raw.ogImageWidth); + const imageHeight = + pickNum(raw.imageHeight) ?? + pickNum(raw['og:image:height']) ?? + pickNum(raw.ogImageHeight); if (!title && !description && !imageUrl) return null; - return { title, description, imageUrl, siteName }; + return { title, description, imageUrl, siteName, imageWidth, imageHeight }; } function useUrlPreview(url: string): UrlPreview | null { @@ -79,7 +118,22 @@ function useUrlPreview(url: string): UrlPreview | null { useEffect(() => { if (previewCache.has(url)) { - setPreview(previewCache.get(url) ?? null); + const cached = previewCache.get(url) ?? null; + // Same cache-seeding as the fresh-fetch branch below — ensures + // the reserved image box is correct even when the preview came + // out of the module cache on a re-render. + if ( + cached?.imageUrl && + cached.imageWidth && + cached.imageHeight && + !embedImageDimsCache.has(cached.imageUrl) + ) { + embedImageDimsCache.set(cached.imageUrl, { + w: cached.imageWidth, + h: cached.imageHeight, + }); + } + setPreview(cached); return; } @@ -112,6 +166,23 @@ function useUrlPreview(url: string): UrlPreview | null { } if (cancelled) return; previewCache.set(url, result); + // Server-provided image dimensions populate the same + // cache the onLoad handler updates — so the + // reserved box is correct on the very first paint of + // the preview card, not only after the image finishes + // decoding. Falls through to the onLoad probe if the + // server didn't have width/height tags. + if ( + result?.imageUrl && + result.imageWidth && + result.imageHeight && + !embedImageDimsCache.has(result.imageUrl) + ) { + embedImageDimsCache.set(result.imageUrl, { + w: result.imageWidth, + h: result.imageHeight, + }); + } setPreview(result); } catch { if (!cancelled) previewCache.set(url, null); @@ -146,14 +217,34 @@ function DirectMediaEmbed({ setPlaying(true); }; + // `preload="metadata"` leaves the