This commit is contained in:
@@ -8,7 +8,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.0.90"
|
versionName "1.1.00"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.90",
|
"version": "1.1.00",
|
||||||
"description": "Brycord - Electron app",
|
"description": "Brycord - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/web",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.90",
|
"version": "1.1.00",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
6
convex/_generated/api.d.ts
vendored
6
convex/_generated/api.d.ts
vendored
@@ -9,6 +9,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as auth from "../auth.js";
|
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 categories from "../categories.js";
|
||||||
import type * as channelKeys from "../channelKeys.js";
|
import type * as channelKeys from "../channelKeys.js";
|
||||||
import type * as channels from "../channels.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 invites from "../invites.js";
|
||||||
import type * as links from "../links.js";
|
import type * as links from "../links.js";
|
||||||
import type * as members from "../members.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 messages from "../messages.js";
|
||||||
import type * as polls from "../polls.js";
|
import type * as polls from "../polls.js";
|
||||||
import type * as presence from "../presence.js";
|
import type * as presence from "../presence.js";
|
||||||
@@ -41,6 +44,8 @@ import type {
|
|||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
|
authActions: typeof authActions;
|
||||||
|
authGuard: typeof authGuard;
|
||||||
categories: typeof categories;
|
categories: typeof categories;
|
||||||
channelKeys: typeof channelKeys;
|
channelKeys: typeof channelKeys;
|
||||||
channels: typeof channels;
|
channels: typeof channels;
|
||||||
@@ -51,6 +56,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
invites: typeof invites;
|
invites: typeof invites;
|
||||||
links: typeof links;
|
links: typeof links;
|
||||||
members: typeof members;
|
members: typeof members;
|
||||||
|
messageActions: typeof messageActions;
|
||||||
messages: typeof messages;
|
messages: typeof messages;
|
||||||
polls: typeof polls;
|
polls: typeof polls;
|
||||||
presence: typeof presence;
|
presence: typeof presence;
|
||||||
|
|||||||
@@ -123,11 +123,7 @@ export const createUserWithProfile = mutation({
|
|||||||
return { error: "Invite expired" };
|
return { error: "Invite expired" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (invite.maxUses !== undefined && invite.uses >= invite.maxUses) {
|
||||||
invite.maxUses !== undefined &&
|
|
||||||
invite.maxUses !== null &&
|
|
||||||
invite.uses >= invite.maxUses
|
|
||||||
) {
|
|
||||||
return { error: "Invite max uses reached" };
|
return { error: "Invite max uses reached" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +236,12 @@ export const getPublicKeys = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user profile (aboutMe, avatar, customStatus)
|
// Internal writer. Public entry point: `authActions.updateProfile`. The
|
||||||
export const updateProfile = mutation({
|
// 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: {
|
args: {
|
||||||
userId: v.id("userProfiles"),
|
userId: v.id("userProfiles"),
|
||||||
displayName: v.optional(v.string()),
|
displayName: v.optional(v.string()),
|
||||||
@@ -278,8 +278,8 @@ export const getMyJoinSoundUrl = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user status
|
// Internal writer. Public entry point: `authActions.updateStatus`.
|
||||||
export const updateStatus = mutation({
|
export const updateStatusInternal = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
userId: v.id("userProfiles"),
|
userId: v.id("userProfiles"),
|
||||||
status: v.string(),
|
status: v.string(),
|
||||||
@@ -343,8 +343,51 @@ export const getUserForRecovery = internalQuery({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set nickname (displayName) for a user
|
// Internal: resolve a userId to the fields needed by the voice-token action
|
||||||
export const setNickname = mutation({
|
// (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: {
|
args: {
|
||||||
actorUserId: v.id("userProfiles"),
|
actorUserId: v.id("userProfiles"),
|
||||||
targetUserId: 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)
|
// Internal writer. Public entry point: `authActions.deleteUser`. Both
|
||||||
export const deleteUser = mutation({
|
// the isAdmin check and the destructive delete live here; the action
|
||||||
|
// layer verifies the caller controls `requestingUserId`.
|
||||||
|
export const deleteUserInternal = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
requestingUserId: v.id("userProfiles"),
|
requestingUserId: v.id("userProfiles"),
|
||||||
targetUserId: v.id("userProfiles"),
|
targetUserId: v.id("userProfiles"),
|
||||||
|
|||||||
109
convex/authActions.ts
Normal file
109
convex/authActions.ts
Normal file
@@ -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<null> => {
|
||||||
|
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<null> => {
|
||||||
|
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<null> => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
63
convex/authGuard.ts
Normal file
63
convex/authGuard.ts
Normal file
@@ -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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,11 +51,7 @@ export const use = query({
|
|||||||
return { error: "Invite expired" };
|
return { error: "Invite expired" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (invite.maxUses !== undefined && invite.uses >= invite.maxUses) {
|
||||||
invite.maxUses !== undefined &&
|
|
||||||
invite.maxUses !== null &&
|
|
||||||
invite.uses >= invite.maxUses
|
|
||||||
) {
|
|
||||||
return { error: "Invite max uses reached" };
|
return { error: "Invite max uses reached" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export const fetchPreview = action({
|
|||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
image: v.optional(v.string()),
|
image: v.optional(v.string()),
|
||||||
siteName: 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(),
|
v.null(),
|
||||||
),
|
),
|
||||||
@@ -89,6 +96,26 @@ export const fetchPreview = action({
|
|||||||
const siteName =
|
const siteName =
|
||||||
pick(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i);
|
pick(/<meta[^>]+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(/<meta[^>]+property=["']og:image:width["'][^>]+content=["']([^"']+)["']/i) ??
|
||||||
|
pickNum(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:width["']/i) ??
|
||||||
|
pickNum(/<meta[^>]+name=["']twitter:image:width["'][^>]+content=["']([^"']+)["']/i);
|
||||||
|
const imageHeight =
|
||||||
|
pickNum(/<meta[^>]+property=["']og:image:height["'][^>]+content=["']([^"']+)["']/i) ??
|
||||||
|
pickNum(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:height["']/i) ??
|
||||||
|
pickNum(/<meta[^>]+name=["']twitter:image:height["'][^>]+content=["']([^"']+)["']/i);
|
||||||
|
|
||||||
// Resolve relative image URLs
|
// Resolve relative image URLs
|
||||||
if (image) {
|
if (image) {
|
||||||
try {
|
try {
|
||||||
@@ -97,7 +124,15 @@ export const fetchPreview = action({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!title && !description && !image) return null;
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
109
convex/messageActions.ts
Normal file
109
convex/messageActions.ts
Normal file
@@ -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<null> => {
|
||||||
|
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<null> => {
|
||||||
|
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<null> => {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, internalMutation } from "./_generated/server";
|
||||||
import { paginationOptsValidator } from "convex/server";
|
import { paginationOptsValidator } from "convex/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { getPublicStorageUrl } from "./storageUrl";
|
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: {
|
args: {
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
senderId: v.id("userProfiles"),
|
senderId: v.id("userProfiles"),
|
||||||
@@ -237,6 +249,9 @@ export const send = mutation({
|
|||||||
},
|
},
|
||||||
returns: v.object({ id: v.id("messages") }),
|
returns: v.object({ id: v.id("messages") }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) {
|
||||||
|
throw new Error("Message too large");
|
||||||
|
}
|
||||||
const id = await ctx.db.insert("messages", {
|
const id = await ctx.db.insert("messages", {
|
||||||
channelId: args.channelId,
|
channelId: args.channelId,
|
||||||
senderId: args.senderId,
|
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: {
|
args: {
|
||||||
messages: v.array(v.object({
|
messages: v.array(v.object({
|
||||||
channelId: v.id("channels"),
|
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: {
|
args: {
|
||||||
id: v.id("messages"),
|
id: v.id("messages"),
|
||||||
|
userId: v.id("userProfiles"),
|
||||||
ciphertext: v.string(),
|
ciphertext: v.string(),
|
||||||
nonce: v.string(),
|
nonce: v.string(),
|
||||||
signature: v.string(),
|
signature: v.string(),
|
||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
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, {
|
await ctx.db.patch(args.id, {
|
||||||
ciphertext: args.ciphertext,
|
ciphertext: args.ciphertext,
|
||||||
nonce: args.nonce,
|
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: {
|
args: {
|
||||||
id: v.id("messages"),
|
id: v.id("messages"),
|
||||||
|
userId: v.id("userProfiles"),
|
||||||
pinned: v.boolean(),
|
pinned: v.boolean(),
|
||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
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 pin messages");
|
||||||
|
}
|
||||||
await ctx.db.patch(args.id, { pinned: args.pinned });
|
await ctx.db.patch(args.id, { pinned: args.pinned });
|
||||||
return null;
|
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") },
|
args: { id: v.id("messages"), userId: v.id("userProfiles") },
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ export default defineSchema({
|
|||||||
username: v.string(),
|
username: v.string(),
|
||||||
expiresAt: v.number(), // timestamp
|
expiresAt: v.number(), // timestamp
|
||||||
}).index("by_channel", ["channelId"])
|
}).index("by_channel", ["channelId"])
|
||||||
.index("by_user", ["userId"]),
|
.index("by_user", ["userId"])
|
||||||
|
.index("by_expires_at", ["expiresAt"]),
|
||||||
|
|
||||||
voiceStates: defineTable({
|
voiceStates: defineTable({
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export const startTyping = mutation({
|
|||||||
const userTyping = existing.find((t) => t.userId === args.userId);
|
const userTyping = existing.find((t) => t.userId === args.userId);
|
||||||
|
|
||||||
if (userTyping) {
|
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 });
|
await ctx.db.patch(userTyping._id, { expiresAt });
|
||||||
} else {
|
} else {
|
||||||
await ctx.db.insert("typingIndicators", {
|
await ctx.db.insert("typingIndicators", {
|
||||||
@@ -30,9 +35,9 @@ export const startTyping = mutation({
|
|||||||
username: args.username,
|
username: args.username,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {});
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -93,12 +98,16 @@ export const cleanExpired = internalMutation({
|
|||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const now = Date.now();
|
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) {
|
for (const t of expired) {
|
||||||
if (t.expiresAt <= now) {
|
|
||||||
await ctx.db.delete(t._id);
|
await ctx.db.delete(t._id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
122
convex/voice.ts
122
convex/voice.ts
@@ -1,12 +1,48 @@
|
|||||||
"use node";
|
"use node";
|
||||||
|
|
||||||
import { action } from "./_generated/server";
|
import { action } from "./_generated/server";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { AccessToken, RoomServiceClient } from "livekit-server-sdk";
|
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.
|
* 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
|
* LiveKit servers run with `room.auto_create: false` reject joins for
|
||||||
* rooms that don't already exist — the client gets a 404 "requested
|
* rooms that don't already exist — the client gets a 404 "requested
|
||||||
* room does not exist" back from the /rtc/v1/validate endpoint. To
|
* 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({
|
export const getToken = action({
|
||||||
args: {
|
args: {
|
||||||
channelId: v.string(),
|
channelId: v.id("channels"),
|
||||||
userId: v.string(),
|
userId: v.id("userProfiles"),
|
||||||
username: v.string(),
|
timestamp: v.number(),
|
||||||
|
signature: v.string(),
|
||||||
},
|
},
|
||||||
returns: v.object({ token: v.string() }),
|
returns: v.union(
|
||||||
handler: async (_ctx, args) => {
|
v.object({ token: v.string() }),
|
||||||
|
v.object({ error: v.string() }),
|
||||||
|
),
|
||||||
|
handler: async (ctx, args): Promise<VoiceTokenResult> => {
|
||||||
|
// 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 apiKey = process.env.LIVEKIT_API_KEY || "devkey";
|
||||||
const apiSecret = process.env.LIVEKIT_API_SECRET || "secret";
|
const apiSecret = process.env.LIVEKIT_API_SECRET || "secret";
|
||||||
const livekitUrl =
|
const livekitUrl =
|
||||||
@@ -43,7 +132,7 @@ export const getToken = action({
|
|||||||
try {
|
try {
|
||||||
const roomService = new RoomServiceClient(httpUrl, apiKey, apiSecret);
|
const roomService = new RoomServiceClient(httpUrl, apiKey, apiSecret);
|
||||||
await roomService.createRoom({
|
await roomService.createRoom({
|
||||||
name: args.channelId,
|
name: channel.channelId,
|
||||||
// Empty rooms auto-destroy after 5 minutes with no participants,
|
// Empty rooms auto-destroy after 5 minutes with no participants,
|
||||||
// matching LiveKit's own default so stale rooms from a crashed
|
// matching LiveKit's own default so stale rooms from a crashed
|
||||||
// client don't pile up forever.
|
// client don't pile up forever.
|
||||||
@@ -56,36 +145,39 @@ export const getToken = action({
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// 409 / "already exists" is expected when a room has already
|
// 409 / "already exists" is expected when a room has already
|
||||||
// been created by an earlier join — swallow it and continue.
|
// 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 status = err?.status ?? err?.statusCode;
|
||||||
const alreadyExists =
|
const alreadyExists =
|
||||||
status === 409 ||
|
status === 409 ||
|
||||||
/already exists/i.test(message) ||
|
/already exists/i.test(errMsg) ||
|
||||||
/AlreadyExists/i.test(message);
|
/AlreadyExists/i.test(errMsg);
|
||||||
if (!alreadyExists) {
|
if (!alreadyExists) {
|
||||||
// Non-fatal: log and fall through to token generation. If the
|
// Non-fatal: log and fall through to token generation. If the
|
||||||
// real issue was misconfiguration the client will surface the
|
// real issue was misconfiguration the client will surface the
|
||||||
// 404 it already does.
|
// 404 it already does.
|
||||||
console.warn("LiveKit createRoom failed:", message);
|
console.warn("LiveKit createRoom failed:", errMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const at = new AccessToken(apiKey, apiSecret, {
|
// Pin identity + name to the server-resolved user. A forged `username`
|
||||||
identity: args.userId,
|
// or `userId` in the args would have already been rejected by the
|
||||||
name: args.username,
|
// 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",
|
ttl: "24h",
|
||||||
});
|
});
|
||||||
|
|
||||||
at.addGrant({
|
at.addGrant({
|
||||||
roomJoin: true,
|
roomJoin: true,
|
||||||
room: args.channelId,
|
room: channel.channelId,
|
||||||
canPublish: true,
|
canPublish: true,
|
||||||
canSubscribe: true,
|
canSubscribe: true,
|
||||||
canPublishData: true,
|
canPublishData: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = await at.toJwt();
|
const token: string = await at.toJwt();
|
||||||
return { token };
|
return { token };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +1,121 @@
|
|||||||
/**
|
/**
|
||||||
* Web platform idle detection using Page Visibility API.
|
* Web platform idle detection.
|
||||||
* Provides a simplified version of the Electron idle API.
|
*
|
||||||
|
* 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 idleCallback = null;
|
||||||
let lastActiveTime = Date.now();
|
let lastActiveTime = Date.now();
|
||||||
|
|
||||||
function handleVisibilityChange() {
|
let idleDetector = null;
|
||||||
if (!idleCallback) return;
|
let idleDetectorAbort = null;
|
||||||
if (document.hidden) {
|
// Guard against stacked listeners when onIdleStateChanged fires twice
|
||||||
idleCallback({ isIdle: true });
|
// without a cleanup in between (StrictMode double-invoke, hot reload,
|
||||||
} else {
|
// 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();
|
lastActiveTime = Date.now();
|
||||||
idleCallback({ isIdle: false });
|
if (idleCallback) idleCallback({ isIdle: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (!document.hidden) {
|
||||||
|
lastActiveTime = Date.now();
|
||||||
|
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 {
|
export default {
|
||||||
getSystemIdleTime() {
|
getSystemIdleTime() {
|
||||||
// Return seconds since last activity (approximation using visibility)
|
|
||||||
if (document.hidden) {
|
|
||||||
return Math.floor((Date.now() - lastActiveTime) / 1000);
|
return Math.floor((Date.now() - lastActiveTime) / 1000);
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onIdleStateChanged(callback) {
|
onIdleStateChanged(callback) {
|
||||||
idleCallback = callback;
|
idleCallback = callback;
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
attachFallbackListeners();
|
||||||
|
void tryStartIdleDetector();
|
||||||
},
|
},
|
||||||
|
|
||||||
removeIdleStateListener() {
|
removeIdleStateListener() {
|
||||||
idleCallback = null;
|
idleCallback = null;
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
detachFallbackListeners();
|
||||||
|
if (idleDetectorAbort) {
|
||||||
|
try {
|
||||||
|
idleDetectorAbort.abort();
|
||||||
|
} catch {
|
||||||
|
/* already aborted */
|
||||||
|
}
|
||||||
|
idleDetectorAbort = null;
|
||||||
|
}
|
||||||
|
idleDetector = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,34 @@
|
|||||||
*/
|
*/
|
||||||
const SESSION_KEY = 'discord-clone-session';
|
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 {
|
export default {
|
||||||
save(data) {
|
save(data) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(SESSION_KEY, JSON.stringify(data));
|
localStorage.setItem(SESSION_KEY, JSON.stringify(data));
|
||||||
return Promise.resolve(true);
|
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);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,12 +4,29 @@
|
|||||||
*/
|
*/
|
||||||
const PREFIX = 'discord-clone-settings:';
|
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 {
|
export default {
|
||||||
get(key) {
|
get(key) {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PREFIX + key);
|
const raw = localStorage.getItem(PREFIX + key);
|
||||||
return Promise.resolve(raw !== null ? JSON.parse(raw) : undefined);
|
return Promise.resolve(raw !== null ? JSON.parse(raw) : undefined);
|
||||||
} catch {
|
} 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);
|
return Promise.resolve(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -18,8 +35,17 @@ export default {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
} catch {
|
} catch (e) {
|
||||||
return Promise.resolve();
|
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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.90",
|
"version": "1.1.00",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.tsx",
|
"main": "src/App.tsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -66,10 +66,20 @@ export function LoginPage() {
|
|||||||
searchDbKey: searchKeys.dak,
|
searchDbKey: searchKeys.dak,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
});
|
});
|
||||||
} catch (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);
|
console.warn('Session persistence unavailable:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
searchCtx?.initialize();
|
searchCtx?.initialize();
|
||||||
navigate('/channels/@me');
|
navigate('/channels/@me');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useConvex, useMutation, useQuery } from 'convex/react';
|
import { useAction, useConvex, useMutation, useQuery } from 'convex/react';
|
||||||
import {
|
import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ChartBar,
|
ChartBar,
|
||||||
@@ -130,7 +130,7 @@ export function ChannelTextarea({
|
|||||||
);
|
);
|
||||||
const keyBundle = allKeys?.find((k) => k.channel_id === channelId) ?? null;
|
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 generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||||
const validateUpload = useMutation(api.files.validateUpload);
|
const validateUpload = useMutation(api.files.validateUpload);
|
||||||
|
|
||||||
@@ -389,6 +389,15 @@ export function ChannelTextarea({
|
|||||||
.map((a) => a.previewUrl)
|
.map((a) => a.previewUrl)
|
||||||
.filter((u): u is string => !!u);
|
.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 {
|
try {
|
||||||
// 1. Text message first (if any). Matches the old client's
|
// 1. Text message first (if any). Matches the old client's
|
||||||
// send-then-attach order so the reply context lands on
|
// 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 { content, iv, tag } = await crypto.encryptData(text, channelKey);
|
||||||
const ciphertext = content + tag;
|
const ciphertext = content + tag;
|
||||||
const signature = await crypto.signMessage(signingKey, ciphertext);
|
const signature = await crypto.signMessage(signingKey, ciphertext);
|
||||||
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`send:${channelId}:${userId}:${authTimestamp}`,
|
||||||
|
);
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
channelId: channelId as any,
|
channelId: channelId as any,
|
||||||
senderId: userId as any,
|
senderId: userId as any,
|
||||||
@@ -405,6 +419,8 @@ export function ChannelTextarea({
|
|||||||
signature,
|
signature,
|
||||||
keyVersion: channelKeyVersion,
|
keyVersion: channelKeyVersion,
|
||||||
replyTo: replyTo ? (replyTo.eventId as any) : undefined,
|
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) {
|
if (userId && channelId) {
|
||||||
void stopTyping({
|
void stopTyping({
|
||||||
channelId: channelId as any,
|
channelId: channelId as any,
|
||||||
@@ -443,6 +456,13 @@ export function ChannelTextarea({
|
|||||||
onCancelReply?.();
|
onCancelReply?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to send message:', 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 { content, iv, tag } = await crypto.encryptData(payload, channelKey);
|
||||||
const ciphertext = content + tag;
|
const ciphertext = content + tag;
|
||||||
const signature = await crypto.signMessage(signingKey, ciphertext);
|
const signature = await crypto.signMessage(signingKey, ciphertext);
|
||||||
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`send:${channelId}:${userId}:${authTimestamp}`,
|
||||||
|
);
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
channelId: channelId as any,
|
channelId: channelId as any,
|
||||||
senderId: userId as any,
|
senderId: userId as any,
|
||||||
@@ -463,6 +488,8 @@ export function ChannelTextarea({
|
|||||||
signature,
|
signature,
|
||||||
keyVersion: channelKeyVersion,
|
keyVersion: channelKeyVersion,
|
||||||
replyTo: replyTo ? (replyTo.eventId as any) : undefined,
|
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 { content, iv, tag } = await crypto.encryptData(payload, channelKey);
|
||||||
const ciphertext = content + tag;
|
const ciphertext = content + tag;
|
||||||
const signature = await crypto.signMessage(signingKey, ciphertext);
|
const signature = await crypto.signMessage(signingKey, ciphertext);
|
||||||
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`send:${channelId}:${userId}:${authTimestamp}`,
|
||||||
|
);
|
||||||
await sendMessage({
|
await sendMessage({
|
||||||
channelId: channelId as any,
|
channelId: channelId as any,
|
||||||
senderId: userId as any,
|
senderId: userId as any,
|
||||||
@@ -484,6 +516,8 @@ export function ChannelTextarea({
|
|||||||
signature,
|
signature,
|
||||||
keyVersion: keyBundle?.key_version ?? 1,
|
keyVersion: keyBundle?.key_version ?? 1,
|
||||||
replyTo: replyTo ? (replyTo.eventId as any) : undefined,
|
replyTo: replyTo ? (replyTo.eventId as any) : undefined,
|
||||||
|
authTimestamp,
|
||||||
|
authSignature,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<Modal.Root isOpen={isOpen} onClose={onClose} size="small">
|
||||||
|
<Modal.Header
|
||||||
|
title="Delete Message"
|
||||||
|
onClose={onClose}
|
||||||
|
className={styles.headerFlush}
|
||||||
|
/>
|
||||||
|
<Modal.Content>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<p className={styles.description}>
|
||||||
|
Are you sure you want to delete this message? This action cannot
|
||||||
|
be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={styles.previewWrap}>
|
||||||
|
<PinnedMessageRow message={message} showEmbeds />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.primaryButton} ${styles.primaryButtonDanger}`}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={busy || !messageId}
|
||||||
|
>
|
||||||
|
{busy ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,20 @@ const TAG_HEX_LEN = 32;
|
|||||||
// same file. Keyed by the remote storage URL.
|
// same file. Keyed by the remote storage URL.
|
||||||
const attachmentCache = new Map<string, string>();
|
const attachmentCache = new Map<string, string>();
|
||||||
|
|
||||||
|
// 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<string, { w: number; h: number }>();
|
||||||
|
|
||||||
|
// 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 {
|
function fromHexString(hex: string): Uint8Array {
|
||||||
const matches = hex.match(/.{1,2}/g) ?? [];
|
const matches = hex.match(/.{1,2}/g) ?? [];
|
||||||
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
|
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
|
||||||
@@ -118,24 +132,26 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'image') {
|
if (kind === 'image') {
|
||||||
// Reserve the exact final layout box up-front so the loaded
|
// Dimension resolution order:
|
||||||
// image lands in the same slot the placeholder occupied — no
|
// 1. metadata.width/height (captured at upload time — modern path)
|
||||||
// post-load height shift, no scroll jump. When the metadata
|
// 2. probedDimsCache for this url (learned on a previous mount)
|
||||||
// carries both width + height we compute the box from them;
|
// 3. PROBE_FALLBACK (fixed 300×200) while we wait for the
|
||||||
// otherwise we fall back to a ratio-aware aspect-ratio so the
|
// first successful <img> onLoad to populate the cache
|
||||||
// browser still reserves a sensible chunk of space.
|
// Fixed fallback (vs. a fluid 4:3 aspect-ratio) is the critical
|
||||||
const hasDims = !!metadata.width && !!metadata.height;
|
// bit: a wrong fluid ratio shifts height when the real image
|
||||||
const maxW = metadata.width ? Math.min(metadata.width, 400) : 300;
|
// lands, which defeats the whole point of reserving space.
|
||||||
const renderedH =
|
const metaDims =
|
||||||
hasDims
|
metadata.width && metadata.height
|
||||||
? Math.round(maxW * (metadata.height! / metadata.width!))
|
? { w: metadata.width, h: metadata.height }
|
||||||
: undefined;
|
: 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 = {
|
const sharedBoxStyle: React.CSSProperties = {
|
||||||
width: maxW,
|
width: maxW,
|
||||||
...(renderedH !== undefined ? { height: renderedH } : {}),
|
height: renderedH,
|
||||||
...(hasDims
|
aspectRatio: `${boxDims.w} / ${boxDims.h}`,
|
||||||
? { aspectRatio: `${metadata.width} / ${metadata.height}` }
|
|
||||||
: { aspectRatio: '4 / 3' }),
|
|
||||||
maxHeight: '50vh',
|
maxHeight: '50vh',
|
||||||
borderRadius: 'var(--radius-lg)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
};
|
};
|
||||||
@@ -165,7 +181,20 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac
|
|||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
cursor: 'pointer',
|
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
|
// Tell the Messages scroller that an attachment
|
||||||
// finished decoding so it can re-pin to bottom if
|
// finished decoding so it can re-pin to bottom if
|
||||||
// the user is still anchored there. Belt-and-
|
// the user is still anchored there. Belt-and-
|
||||||
|
|||||||
@@ -113,26 +113,35 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
|
|||||||
}, [trendingAction, categoriesAction]);
|
}, [trendingAction, categoriesAction]);
|
||||||
|
|
||||||
// Debounced search — fires 350ms after the last keystroke so we
|
// 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(() => {
|
useEffect(() => {
|
||||||
const q = search.trim();
|
const q = search.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
const t = window.setTimeout(async () => {
|
const t = window.setTimeout(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res: any = await searchAction({ q, limit: 24 });
|
const res: any = await searchAction({ q, limit: 24 });
|
||||||
|
if (cancelled) return;
|
||||||
setSearchResults(res?.results ?? []);
|
setSearchResults(res?.results ?? []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (cancelled) return;
|
||||||
setError(err?.message ?? 'Search failed.');
|
setError(err?.message ?? 'Search failed.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
return () => window.clearTimeout(t);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(t);
|
||||||
|
};
|
||||||
}, [search, searchAction]);
|
}, [search, searchAction]);
|
||||||
|
|
||||||
const handlePick = (gif: GifResult) => {
|
const handlePick = (gif: GifResult) => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface UrlPreview {
|
|||||||
description?: string;
|
description?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
siteName?: string;
|
siteName?: string;
|
||||||
|
imageWidth?: number;
|
||||||
|
imageHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIDEO_HOSTS = [
|
const VIDEO_HOSTS = [
|
||||||
@@ -51,6 +53,23 @@ function isVideoUrl(url: string): boolean {
|
|||||||
// avoid hammering the fetcher for URLs that will never resolve.
|
// avoid hammering the fetcher for URLs that will never resolve.
|
||||||
const previewCache = new Map<string, UrlPreview | null>();
|
const previewCache = new Map<string, UrlPreview | null>();
|
||||||
|
|
||||||
|
// Natural dimensions learned on first successful <img> 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<string, { w: number; h: number }>();
|
||||||
|
|
||||||
|
// 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 {
|
function normaliseMetadata(raw: any): UrlPreview | null {
|
||||||
if (!raw || typeof raw !== 'object') return 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;
|
raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined;
|
||||||
const siteName =
|
const siteName =
|
||||||
raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined;
|
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;
|
if (!title && !description && !imageUrl) return null;
|
||||||
return { title, description, imageUrl, siteName };
|
return { title, description, imageUrl, siteName, imageWidth, imageHeight };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useUrlPreview(url: string): UrlPreview | null {
|
function useUrlPreview(url: string): UrlPreview | null {
|
||||||
@@ -79,7 +118,22 @@ function useUrlPreview(url: string): UrlPreview | null {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previewCache.has(url)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +166,23 @@ function useUrlPreview(url: string): UrlPreview | null {
|
|||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
previewCache.set(url, result);
|
previewCache.set(url, result);
|
||||||
|
// Server-provided image dimensions populate the same
|
||||||
|
// cache the <img> 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);
|
setPreview(result);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) previewCache.set(url, null);
|
if (!cancelled) previewCache.set(url, null);
|
||||||
@@ -146,14 +217,34 @@ function DirectMediaEmbed({
|
|||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `preload="metadata"` leaves the <video> element at zero height
|
||||||
|
// until `loadedmetadata` fires — that was a measurable source of
|
||||||
|
// scroll jump. Wrap it in an aspect-ratio box so the space is
|
||||||
|
// reserved from the first paint. 16:9 is the overwhelming majority
|
||||||
|
// of web video; when the real metadata lands and differs slightly
|
||||||
|
// the ResizeObserver catches it, but the gross box is already
|
||||||
|
// there.
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.embed} ${styles.embedBare}`}>
|
<div className={`${styles.embed} ${styles.embedBare}`}>
|
||||||
<div className={styles.directVideoWrapper}>
|
<div
|
||||||
|
className={styles.directVideoWrapper}
|
||||||
|
style={{
|
||||||
|
aspectRatio: DIRECT_VIDEO_FALLBACK_RATIO,
|
||||||
|
width: 400,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className={styles.directVideo}
|
className={styles.directVideo}
|
||||||
src={url}
|
src={url}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
onLoadedMetadata={() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('brycord:attachment-loaded'),
|
||||||
|
);
|
||||||
|
}}
|
||||||
onPause={() => {
|
onPause={() => {
|
||||||
if (videoRef.current && videoRef.current.ended) {
|
if (videoRef.current && videoRef.current.ended) {
|
||||||
videoRef.current.controls = false;
|
videoRef.current.controls = false;
|
||||||
@@ -193,6 +284,15 @@ function DirectMediaEmbed({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct image embed: reserve a box from the probed cache (or a
|
||||||
|
// fixed fallback) so the image lands in a slot of known height
|
||||||
|
// instead of expanding the wrapper from zero. `loading="lazy"` was
|
||||||
|
// here but removed — a direct-image embed is always rendered in
|
||||||
|
// view when it first mounts, and the deferred decode defeats the
|
||||||
|
// scroll anchor window we're trying to hold onto.
|
||||||
|
const probed = embedImageDimsCache.get(url) ?? EMBED_IMG_FALLBACK;
|
||||||
|
const boxW = Math.min(probed.w, 400);
|
||||||
|
const boxH = Math.round(boxW * (probed.h / probed.w));
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.embed} ${styles.embedBare}`}>
|
<div className={`${styles.embed} ${styles.embedBare}`}>
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
@@ -200,7 +300,23 @@ function DirectMediaEmbed({
|
|||||||
className={styles.directImage}
|
className={styles.directImage}
|
||||||
src={url}
|
src={url}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
decoding="async"
|
||||||
|
width={boxW}
|
||||||
|
height={boxH}
|
||||||
|
style={{
|
||||||
|
aspectRatio: `${probed.w} / ${probed.h}`,
|
||||||
|
}}
|
||||||
|
onLoad={(e) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
const w = img.naturalWidth;
|
||||||
|
const h = img.naturalHeight;
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
embedImageDimsCache.set(url, { w, h });
|
||||||
|
}
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('brycord:attachment-loaded'),
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,13 +379,49 @@ function UrlPreviewEmbed({ url }: { url: string }) {
|
|||||||
<div className={styles.description}>{preview.description}</div>
|
<div className={styles.description}>{preview.description}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasImage && (
|
{hasImage && (() => {
|
||||||
<div className={styles.mediaContainer}>
|
// Reserve a box for the OG image so the card's
|
||||||
|
// final height is known before the image loads.
|
||||||
|
// Without this the card appears title-first, then
|
||||||
|
// expands downward as the image decodes — the
|
||||||
|
// classic link-preview height shift.
|
||||||
|
const imgUrl = preview.imageUrl!;
|
||||||
|
const probed =
|
||||||
|
embedImageDimsCache.get(imgUrl) ?? EMBED_IMG_FALLBACK;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.mediaContainer}
|
||||||
|
style={{
|
||||||
|
aspectRatio: `${probed.w} / ${probed.h}`,
|
||||||
|
// max-height matches .mediaImage CSS so
|
||||||
|
// the container never exceeds the
|
||||||
|
// image's own cap and the reserved
|
||||||
|
// space matches the rendered space.
|
||||||
|
maxHeight: 300,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
className={styles.mediaImage}
|
className={styles.mediaImage}
|
||||||
src={preview.imageUrl}
|
src={imgUrl}
|
||||||
alt={preview.title || ''}
|
alt={preview.title || ''}
|
||||||
loading="lazy"
|
decoding="async"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
onLoad={(e) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
const w = img.naturalWidth;
|
||||||
|
const h = img.naturalHeight;
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
embedImageDimsCache.set(imgUrl, { w, h });
|
||||||
|
}
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('brycord:attachment-loaded'),
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<a
|
<a
|
||||||
@@ -289,7 +441,8 @@ function UrlPreviewEmbed({ url }: { url: string }) {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
MemberProfilePopout,
|
MemberProfilePopout,
|
||||||
type MemberProfilePopoutMember,
|
type MemberProfilePopoutMember,
|
||||||
} from '../member/MemberProfilePopout';
|
} from '../member/MemberProfilePopout';
|
||||||
|
import { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||||
import { PinConfirmationModal } from './PinConfirmationModal';
|
import { PinConfirmationModal } from './PinConfirmationModal';
|
||||||
import { ReactionsModal } from './ReactionsModal';
|
import { ReactionsModal } from './ReactionsModal';
|
||||||
import { Tooltip } from '@discord-clone/ui';
|
import { Tooltip } from '@discord-clone/ui';
|
||||||
@@ -23,6 +24,7 @@ import { reactionKeyToName } from '../../utils/emojiLookup';
|
|||||||
import type { PinnedMessage } from './PinnedMessageRow';
|
import type { PinnedMessage } from './PinnedMessageRow';
|
||||||
import { TwemojiImg } from './TwemojiImg';
|
import { TwemojiImg } from './TwemojiImg';
|
||||||
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
|
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
|
||||||
|
import { extractUrls, isGifOnlyContent } from '../../utils/messageUrls';
|
||||||
import styles from './MessageGroup.module.css';
|
import styles from './MessageGroup.module.css';
|
||||||
|
|
||||||
interface MessageGroupProps {
|
interface MessageGroupProps {
|
||||||
@@ -31,32 +33,6 @@ interface MessageGroupProps {
|
|||||||
onReply?: (eventId: string, username: string) => void;
|
onReply?: (eventId: string, username: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
|
|
||||||
|
|
||||||
function extractUrls(text: string): string[] {
|
|
||||||
const matches = text.match(URL_REGEX) ?? [];
|
|
||||||
// Strip trailing punctuation that's almost never part of the URL but
|
|
||||||
// commonly butts up against one in prose ("see https://foo.com.").
|
|
||||||
const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
|
|
||||||
return Array.from(new Set(cleaned));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when a message body is entirely made up of one or more GIF
|
|
||||||
* URLs plus whitespace — i.e. the user posted a GIF from the
|
|
||||||
* picker and there's nothing worth showing as text. The render
|
|
||||||
* path hides the <MessageContent> block in that case so only the
|
|
||||||
* embedded preview appears. */
|
|
||||||
function isGifOnlyContent(text: string): boolean {
|
|
||||||
const urls = extractUrls(text);
|
|
||||||
if (urls.length === 0) return false;
|
|
||||||
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
|
|
||||||
let remainder = text;
|
|
||||||
for (const u of urls) {
|
|
||||||
remainder = remainder.split(u).join('');
|
|
||||||
}
|
|
||||||
return remainder.trim().length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discord-style relative timestamp:
|
* Discord-style relative timestamp:
|
||||||
* - Same calendar day → `Today at 7:08 PM`
|
* - Same calendar day → `Today at 7:08 PM`
|
||||||
@@ -105,7 +81,6 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
|||||||
userId: m.id,
|
userId: m.id,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
const removeMessage = useMutation(api.messages.remove);
|
|
||||||
const addReaction = useMutation(api.reactions.add);
|
const addReaction = useMutation(api.reactions.add);
|
||||||
const removeReaction = useMutation(api.reactions.remove);
|
const removeReaction = useMutation(api.reactions.remove);
|
||||||
|
|
||||||
@@ -276,13 +251,29 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
|||||||
setReactPicker(null);
|
setReactPicker(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (messageId: string) => {
|
// Delete flows through the DeleteConfirmationModal — the old
|
||||||
if (!myUserId) return;
|
// inline delete fired as soon as the user clicked the trash icon
|
||||||
try {
|
// which was easy to trigger by accident on a misclick. The modal
|
||||||
await removeMessage({ id: messageId as any, userId: myUserId as any });
|
// also holds the actual api.messageActions.remove call so the
|
||||||
} catch (err) {
|
// signing logic lives in one place.
|
||||||
console.error('Failed to delete message:', err);
|
const [deleteTarget, setDeleteTarget] = useState<{
|
||||||
}
|
id: string;
|
||||||
|
preview: PinnedMessage;
|
||||||
|
} | null>(null);
|
||||||
|
const handleDelete = (messageId: string) => {
|
||||||
|
const msg = messages.find((m) => m.id === messageId);
|
||||||
|
if (!msg) return;
|
||||||
|
setDeleteTarget({
|
||||||
|
id: msg.id,
|
||||||
|
preview: {
|
||||||
|
id: msg.id,
|
||||||
|
authorName: msg.authorName,
|
||||||
|
authorAvatarUrl: msg.authorAvatarUrl,
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
attachments: msg.attachments,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleReaction = async (messageId: string, emoji: string, me: boolean) => {
|
const handleToggleReaction = async (messageId: string, emoji: string, me: boolean) => {
|
||||||
@@ -690,6 +681,13 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
|||||||
variant={pinTarget?.variant ?? 'pin'}
|
variant={pinTarget?.variant ?? 'pin'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
isOpen={!!deleteTarget}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
messageId={deleteTarget?.id ?? null}
|
||||||
|
message={deleteTarget?.preview ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
{authorPopout && (
|
{authorPopout && (
|
||||||
<MemberProfilePopout
|
<MemberProfilePopout
|
||||||
anchorRect={authorPopout.anchorRect}
|
anchorRect={authorPopout.anchorRect}
|
||||||
|
|||||||
@@ -50,15 +50,34 @@ export interface DecryptedMessage {
|
|||||||
const TAG_LENGTH = 32;
|
const TAG_LENGTH = 32;
|
||||||
|
|
||||||
// Small LRU-ish cache for decrypted messages so re-renders don't redecrypt.
|
// Small LRU-ish cache for decrypted messages so re-renders don't redecrypt.
|
||||||
|
// Entries are namespaced by the viewing user's id so decrypted plaintexts
|
||||||
|
// from a previous login can't bleed into a different user on the same
|
||||||
|
// page load (logout normally triggers a hard reload, but hot-reload in
|
||||||
|
// dev and rare reload failures can skip that path).
|
||||||
const decryptionCache = new Map<string, string>();
|
const decryptionCache = new Map<string, string>();
|
||||||
const MAX_CACHE = 2000;
|
const MAX_CACHE = 2000;
|
||||||
|
|
||||||
function cacheSet(id: string, content: string) {
|
function namespacedKey(userId: string | null, id: string): string {
|
||||||
|
return `${userId ?? 'anon'}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheSet(userId: string | null, id: string, content: string) {
|
||||||
|
const key = namespacedKey(userId, id);
|
||||||
if (decryptionCache.size >= MAX_CACHE) {
|
if (decryptionCache.size >= MAX_CACHE) {
|
||||||
const firstKey = decryptionCache.keys().next().value;
|
const firstKey = decryptionCache.keys().next().value;
|
||||||
if (firstKey !== undefined) decryptionCache.delete(firstKey);
|
if (firstKey !== undefined) decryptionCache.delete(firstKey);
|
||||||
}
|
}
|
||||||
decryptionCache.set(id, content);
|
decryptionCache.set(key, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheGet(userId: string | null, id: string): string | undefined {
|
||||||
|
return decryptionCache.get(namespacedKey(userId, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposed for the logout hook to flush plaintext from memory proactively,
|
||||||
|
// independently of the full-page reload useLogout does after this returns.
|
||||||
|
export function clearDecryptionCache(): void {
|
||||||
|
decryptionCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Day divider helpers ─────────────────────────────────────────────
|
// ── Day divider helpers ─────────────────────────────────────────────
|
||||||
@@ -308,6 +327,54 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initial-load veil: the scroller starts invisible on every channel
|
||||||
|
// switch and reveals only once the last ~20 messages are decrypted
|
||||||
|
// (or a hard fallback timeout fires). The reveal moment is also
|
||||||
|
// when we perform the authoritative scroll-to-bottom — that way the
|
||||||
|
// user never sees the pre-hydration state that used to cause the
|
||||||
|
// viewport to visibly float upward as message bodies filled in.
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const readyTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Synchronous cache hydration. Runs as a layout effect so the
|
||||||
|
// setState + re-render happens before paint: warm-cache channel
|
||||||
|
// switches (the common case) render with real message text on the
|
||||||
|
// first frame instead of briefly showing empty `content: ''` bodies
|
||||||
|
// that then grow as the async effect below fills them in. That
|
||||||
|
// growth was the primary cause of the "scrolls to bottom, then
|
||||||
|
// jumps up" bug — no hydration wave, no jump.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!pagedMessages || pagedMessages.length === 0) return;
|
||||||
|
let changed = false;
|
||||||
|
let next: Map<string, string> | null = null;
|
||||||
|
for (const msg of pagedMessages as any[]) {
|
||||||
|
const id = msg.id as string;
|
||||||
|
if (decryptedMap.has(id)) continue;
|
||||||
|
const cached = cacheGet(userId, id);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
if (!next) next = new Map(decryptedMap);
|
||||||
|
next.set(id, cached);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed && next) setDecryptedMap(next);
|
||||||
|
let replyChanged = false;
|
||||||
|
let nextReply: Map<string, string> | null = null;
|
||||||
|
for (const msg of pagedMessages as any[]) {
|
||||||
|
const id = msg.id as string;
|
||||||
|
if (replyPreviewMap.has(id)) continue;
|
||||||
|
if (!msg.replyToContent) continue;
|
||||||
|
const cached = cacheGet(userId, `reply:${id}`);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
if (!nextReply) nextReply = new Map(replyPreviewMap);
|
||||||
|
nextReply.set(id, cached);
|
||||||
|
replyChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (replyChanged && nextReply) setReplyPreviewMap(nextReply);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pagedMessages, userId]);
|
||||||
|
|
||||||
// Helper: try to scroll to whatever's in `pendingJumpRef`. If the
|
// Helper: try to scroll to whatever's in `pendingJumpRef`. If the
|
||||||
// target is in the DOM we scroll + flash, clear the pending ref,
|
// target is in the DOM we scroll + flash, clear the pending ref,
|
||||||
// and we're done. Otherwise we kick off another paginate-up so
|
// and we're done. Otherwise we kick off another paginate-up so
|
||||||
@@ -369,59 +436,85 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
|
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const next = new Map(decryptedMap);
|
// Walk once, bucket each message into either a synchronous
|
||||||
let changed = false;
|
// sentinel (invalid ciphertext, missing key) or an async
|
||||||
|
// decrypt job. The synchronous hits are applied in the same
|
||||||
|
// setState as the async results, so the UI never flashes
|
||||||
|
// through an intermediate "some decrypted, some not" state.
|
||||||
|
type Job =
|
||||||
|
| { kind: 'sentinel'; id: string; value: string }
|
||||||
|
| {
|
||||||
|
kind: 'decrypt';
|
||||||
|
id: string;
|
||||||
|
contentHex: string;
|
||||||
|
nonce: string;
|
||||||
|
tag: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
const jobs: Job[] = [];
|
||||||
for (const msg of pagedMessages as any[]) {
|
for (const msg of pagedMessages as any[]) {
|
||||||
const id = msg.id as string;
|
const id = msg.id as string;
|
||||||
if (next.has(id)) continue;
|
if (decryptedMap.has(id)) continue;
|
||||||
const cached = decryptionCache.get(id);
|
if (cacheGet(userId, id) !== undefined) continue;
|
||||||
if (cached) {
|
|
||||||
next.set(id, cached);
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
|
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
|
||||||
next.set(id, '[Invalid Encrypted Message]');
|
jobs.push({ kind: 'sentinel', id, value: '[Invalid Encrypted Message]' });
|
||||||
changed = true;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Pick the key matching this message's version. Messages
|
|
||||||
// that predate key rotation default to version 1. If the
|
|
||||||
// exact version is missing (shouldn't happen normally),
|
|
||||||
// fall back to the latest key and try that so we never
|
|
||||||
// lock the user out of their own history.
|
|
||||||
const msgVersion: number = msg.keyVersion ?? 1;
|
const msgVersion: number = msg.keyVersion ?? 1;
|
||||||
const keyForVersion =
|
const keyForVersion =
|
||||||
channelKeysByVersion.get(msgVersion) ?? channelKey;
|
channelKeysByVersion.get(msgVersion) ?? channelKey;
|
||||||
if (!keyForVersion) {
|
if (!keyForVersion) {
|
||||||
next.set(id, '[Unable to decrypt]');
|
jobs.push({ kind: 'sentinel', id, value: '[Unable to decrypt]' });
|
||||||
changed = true;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const tag = msg.ciphertext.slice(-TAG_LENGTH);
|
jobs.push({
|
||||||
const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
|
kind: 'decrypt',
|
||||||
|
id,
|
||||||
|
contentHex: msg.ciphertext.slice(0, -TAG_LENGTH),
|
||||||
|
tag: msg.ciphertext.slice(-TAG_LENGTH),
|
||||||
|
nonce: msg.nonce,
|
||||||
|
key: keyForVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (jobs.length === 0) return;
|
||||||
|
// Fan out decrypts in parallel — 50 AES-GCM ops finish in
|
||||||
|
// one round-trip to the crypto worker instead of 50 sequential
|
||||||
|
// awaits. Collapses what used to be N setState waves (one
|
||||||
|
// after each decrypt in the original serial loop) into one.
|
||||||
|
const results = await Promise.all(
|
||||||
|
jobs.map(async (j) => {
|
||||||
|
if (j.kind === 'sentinel') {
|
||||||
|
return { id: j.id, value: j.value, cache: false };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const plaintext = await crypto.decryptData(
|
const plaintext = await crypto.decryptData(
|
||||||
contentHex,
|
j.contentHex,
|
||||||
keyForVersion,
|
j.key,
|
||||||
msg.nonce,
|
j.nonce,
|
||||||
tag,
|
j.tag,
|
||||||
|
);
|
||||||
|
return { id: j.id, value: plaintext, cache: true };
|
||||||
|
} catch {
|
||||||
|
return { id: j.id, value: '[Unable to decrypt]', cache: false };
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
cacheSet(id, plaintext);
|
const next = new Map(decryptedMap);
|
||||||
next.set(id, plaintext);
|
for (const r of results) {
|
||||||
changed = true;
|
if (r.cache) cacheSet(userId, r.id, r.value);
|
||||||
} catch {
|
next.set(r.id, r.value);
|
||||||
next.set(id, '[Unable to decrypt]');
|
|
||||||
changed = true;
|
|
||||||
}
|
}
|
||||||
}
|
setDecryptedMap(next);
|
||||||
if (changed && !cancelled) setDecryptedMap(next);
|
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [pagedMessages, channelKeysByVersion, channelKey]);
|
// `userId` gates the cacheGet/cacheSet namespace below; without
|
||||||
|
// it a quick logout/login in the same tab would surface the
|
||||||
|
// previous user's cached plaintext under the new identity.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pagedMessages, channelKeysByVersion, channelKey, userId]);
|
||||||
|
|
||||||
// Reply preview decryption — same pattern as the main loop but
|
// Reply preview decryption — same pattern as the main loop but
|
||||||
// keyed by child message id and using the parent's `replyToContent`
|
// keyed by child message id and using the parent's `replyToContent`
|
||||||
@@ -431,11 +524,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
|
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const next = new Map(replyPreviewMap);
|
type Job = {
|
||||||
let changed = false;
|
id: string;
|
||||||
|
cacheKey: string;
|
||||||
|
contentHex: string;
|
||||||
|
nonce: string;
|
||||||
|
tag: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
const jobs: Job[] = [];
|
||||||
for (const msg of pagedMessages as any[]) {
|
for (const msg of pagedMessages as any[]) {
|
||||||
const id = msg.id as string;
|
const id = msg.id as string;
|
||||||
if (next.has(id)) continue;
|
if (replyPreviewMap.has(id)) continue;
|
||||||
if (
|
if (
|
||||||
!msg.replyToContent ||
|
!msg.replyToContent ||
|
||||||
!msg.replyToNonce ||
|
!msg.replyToNonce ||
|
||||||
@@ -444,26 +544,34 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cacheKey = `reply:${id}`;
|
const cacheKey = `reply:${id}`;
|
||||||
const cached = decryptionCache.get(cacheKey);
|
if (cacheGet(userId, cacheKey) !== undefined) continue;
|
||||||
if (cached) {
|
|
||||||
next.set(id, cached);
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const replyVersion: number = msg.replyToKeyVersion ?? 1;
|
const replyVersion: number = msg.replyToKeyVersion ?? 1;
|
||||||
const keyForVersion =
|
const keyForVersion =
|
||||||
channelKeysByVersion.get(replyVersion) ?? channelKey;
|
channelKeysByVersion.get(replyVersion) ?? channelKey;
|
||||||
if (!keyForVersion) continue;
|
if (!keyForVersion) continue;
|
||||||
const tag = msg.replyToContent.slice(-TAG_LENGTH);
|
jobs.push({
|
||||||
const contentHex = msg.replyToContent.slice(0, -TAG_LENGTH);
|
id,
|
||||||
|
cacheKey,
|
||||||
|
contentHex: msg.replyToContent.slice(0, -TAG_LENGTH),
|
||||||
|
tag: msg.replyToContent.slice(-TAG_LENGTH),
|
||||||
|
nonce: msg.replyToNonce,
|
||||||
|
key: keyForVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (jobs.length === 0) return;
|
||||||
|
// Parallel decrypt — collapses the per-message setState waves
|
||||||
|
// of the original serial loop into one, so the reply-preview
|
||||||
|
// layer can't produce a second reflow after the main body
|
||||||
|
// decryption already shifted heights.
|
||||||
|
const results = await Promise.all(
|
||||||
|
jobs.map(async (j) => {
|
||||||
try {
|
try {
|
||||||
const plaintext = await crypto.decryptData(
|
const plaintext = await crypto.decryptData(
|
||||||
contentHex,
|
j.contentHex,
|
||||||
keyForVersion,
|
j.key,
|
||||||
msg.replyToNonce,
|
j.nonce,
|
||||||
tag,
|
j.tag,
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
|
||||||
// Strip JSON wrappers so the preview shows the
|
// Strip JSON wrappers so the preview shows the
|
||||||
// human-readable text, not raw {"text":"…"} dumps.
|
// human-readable text, not raw {"text":"…"} dumps.
|
||||||
let preview = plaintext;
|
let preview = plaintext;
|
||||||
@@ -484,20 +592,30 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* plain text */
|
/* plain text */
|
||||||
}
|
}
|
||||||
cacheSet(cacheKey, preview);
|
cacheSet(userId, j.cacheKey, preview);
|
||||||
next.set(id, preview);
|
return { id: j.id, preview };
|
||||||
changed = true;
|
|
||||||
} catch {
|
} catch {
|
||||||
/* leave unset — UI shows the missing-context fallback */
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
const next = new Map(replyPreviewMap);
|
||||||
|
let changed = false;
|
||||||
|
for (const r of results) {
|
||||||
|
if (r) {
|
||||||
|
next.set(r.id, r.preview);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed && !cancelled) setReplyPreviewMap(next);
|
if (changed) setReplyPreviewMap(next);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
// Same userId cache-namespace concern as the main loop.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [pagedMessages, channelKeysByVersion, channelKey]);
|
}, [pagedMessages, channelKeysByVersion, channelKey, userId]);
|
||||||
|
|
||||||
const decrypted: DecryptedMessage[] = useMemo(() => {
|
const decrypted: DecryptedMessage[] = useMemo(() => {
|
||||||
if (!pagedMessages) return [];
|
if (!pagedMessages) return [];
|
||||||
@@ -613,15 +731,102 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
const scrollAnchorRef = useRef<{ bottomOffset: number } | null>(null);
|
const scrollAnchorRef = useRef<{ bottomOffset: number } | null>(null);
|
||||||
const restoreActiveRef = useRef(false);
|
const restoreActiveRef = useRef(false);
|
||||||
|
|
||||||
// On channel switch, re-pin and scroll to bottom.
|
// On channel switch, reset all scroll-state refs and drop the veil
|
||||||
|
// (reveal gate). The actual scroll-to-bottom is deferred to a
|
||||||
|
// separate effect that runs once the tail of the list is decrypted,
|
||||||
|
// so we pin against the final rendered height instead of the
|
||||||
|
// empty-content height that used to cause the visible jump.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
pinnedRef.current = true;
|
pinnedRef.current = true;
|
||||||
scrollAnchorRef.current = null;
|
scrollAnchorRef.current = null;
|
||||||
restoreActiveRef.current = false;
|
restoreActiveRef.current = false;
|
||||||
const el = scrollerRef.current;
|
setReady(false);
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (readyTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(readyTimerRef.current);
|
||||||
|
}
|
||||||
|
// Hard safety net: even if decryption stalls (missing key,
|
||||||
|
// network flake), drop the veil so the user always sees *some*
|
||||||
|
// channel state within ~400ms of switching. `[Unable to decrypt]`
|
||||||
|
// sentinels render at stable height too, so this fallback is
|
||||||
|
// safe — we aren't waiting for real text, we're waiting for
|
||||||
|
// *anything* to be populated so heights are final.
|
||||||
|
readyTimerRef.current = window.setTimeout(() => {
|
||||||
|
readyTimerRef.current = null;
|
||||||
|
setReady(true);
|
||||||
|
}, 400);
|
||||||
|
return () => {
|
||||||
|
if (readyTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(readyTimerRef.current);
|
||||||
|
readyTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [channelId]);
|
}, [channelId]);
|
||||||
|
|
||||||
|
// Tail-ready detection: drop the veil once the newest ~20 messages
|
||||||
|
// (the ones visible on the first screen) have entries in
|
||||||
|
// `decryptedMap`. A message with an error sentinel (e.g.
|
||||||
|
// `[Unable to decrypt]`) counts as ready because its height is
|
||||||
|
// stable — we only care that heights won't grow after reveal.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (ready) return;
|
||||||
|
if (!pagedMessages) return;
|
||||||
|
const msgs = pagedMessages as any[];
|
||||||
|
if (msgs.length === 0) {
|
||||||
|
setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// pagedMessages is newest-first (the `decrypted` memo reverses
|
||||||
|
// it for render). The visible tail is the first ~20 entries.
|
||||||
|
const tailCount = Math.min(20, msgs.length);
|
||||||
|
for (let i = 0; i < tailCount; i++) {
|
||||||
|
if (!decryptedMap.has(msgs[i].id)) return;
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
}, [ready, pagedMessages, decryptedMap]);
|
||||||
|
|
||||||
|
// Scroll-to-bottom + stubborn-bottom settle. Runs once `ready` flips
|
||||||
|
// true on the current channel. The initial rAF-delayed pin absorbs
|
||||||
|
// the reveal-frame layout; the later timeouts form a "stubborn
|
||||||
|
// bottom" window that keeps re-pinning for 500ms after reveal to
|
||||||
|
// catch late-loading content (OG images, cross-origin canvas taints
|
||||||
|
// in PausedGif, anything the observer's ResizeObserver misses
|
||||||
|
// because the placeholder and final content have identical boxes).
|
||||||
|
// The `pinnedRef` guard means a user who scrolls up during the
|
||||||
|
// settle window is never dragged back down.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
// Immediate pre-paint pin so the first painted frame is at the
|
||||||
|
// bottom. Everything after this is belt-and-suspenders.
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
const pin = () => {
|
||||||
|
if (!pinnedRef.current) return;
|
||||||
|
const cur = scrollerRef.current;
|
||||||
|
if (!cur) return;
|
||||||
|
restoreActiveRef.current = true;
|
||||||
|
cur.scrollTop = cur.scrollHeight;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
restoreActiveRef.current = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const rafs: number[] = [];
|
||||||
|
const timers: number[] = [];
|
||||||
|
rafs.push(
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
pin();
|
||||||
|
rafs.push(requestAnimationFrame(pin));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
timers.push(window.setTimeout(pin, 80));
|
||||||
|
timers.push(window.setTimeout(pin, 250));
|
||||||
|
timers.push(window.setTimeout(pin, 500));
|
||||||
|
return () => {
|
||||||
|
rafs.forEach((h) => cancelAnimationFrame(h));
|
||||||
|
timers.forEach((h) => window.clearTimeout(h));
|
||||||
|
};
|
||||||
|
}, [ready, channelId]);
|
||||||
|
|
||||||
// Observe DOM mutations + resizes inside the scroller. If the user
|
// Observe DOM mutations + resizes inside the scroller. If the user
|
||||||
// is pinned to bottom, snap to bottom on every content change. If
|
// is pinned to bottom, snap to bottom on every content change. If
|
||||||
// the user triggered a paginate-up, preserve their position by
|
// the user triggered a paginate-up, preserve their position by
|
||||||
@@ -630,10 +835,14 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const onContentChange = () => {
|
// Single rAF-batched handler so MutationObserver + ResizeObserver +
|
||||||
// Pagination anchor wins over pinned — the user is clearly
|
// window resize + attachment-loaded events all collapse into one
|
||||||
// scrolled up and reading older context, so we must NOT
|
// scroll write per frame. Without batching, an image loading could
|
||||||
// slam them to the bottom.
|
// fire both observers in the same tick and cause a double
|
||||||
|
// `scrollTop = scrollHeight`.
|
||||||
|
let rafHandle: number | null = null;
|
||||||
|
const runContentChange = () => {
|
||||||
|
rafHandle = null;
|
||||||
const anchor = scrollAnchorRef.current;
|
const anchor = scrollAnchorRef.current;
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
// Restore the same distance-from-bottom on every
|
// Restore the same distance-from-bottom on every
|
||||||
@@ -654,6 +863,10 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const onContentChange = () => {
|
||||||
|
if (rafHandle !== null) return;
|
||||||
|
rafHandle = requestAnimationFrame(runContentChange);
|
||||||
|
};
|
||||||
|
|
||||||
const mutationObs = new MutationObserver(onContentChange);
|
const mutationObs = new MutationObserver(onContentChange);
|
||||||
mutationObs.observe(el, { childList: true, subtree: true });
|
mutationObs.observe(el, { childList: true, subtree: true });
|
||||||
@@ -690,6 +903,10 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (rafHandle !== null) {
|
||||||
|
cancelAnimationFrame(rafHandle);
|
||||||
|
rafHandle = null;
|
||||||
|
}
|
||||||
mutationObs.disconnect();
|
mutationObs.disconnect();
|
||||||
resizeObs?.disconnect();
|
resizeObs?.disconnect();
|
||||||
window.removeEventListener('resize', onWindowResize);
|
window.removeEventListener('resize', onWindowResize);
|
||||||
@@ -713,12 +930,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
pinnedRef.current = distanceFromBottom < 150;
|
pinnedRef.current = distanceFromBottom < 150;
|
||||||
|
|
||||||
// If the user has visibly moved away from the loader region
|
// Clear the anchor in two cases:
|
||||||
// (far enough down that another paginate-up can't trigger),
|
// 1. User scrolled well past the loader region (>200px) — a
|
||||||
// clear the anchor. This lets the next near-top scroll start
|
// fresh near-top scroll should start a new pagination cycle.
|
||||||
// a fresh pagination cycle instead of chaining onto stale
|
// 2. Pagination is no longer loading-more (either CanLoadMore
|
||||||
// coords from a previous one.
|
// has been re-armed or we've Exhausted). Without this,
|
||||||
if (scrollAnchorRef.current && el.scrollTop > 200) {
|
// decryption-driven reflows in the 80-200px band would keep
|
||||||
|
// firing anchor-restore and make the scroll feel sticky
|
||||||
|
// after older history loaded.
|
||||||
|
if (
|
||||||
|
scrollAnchorRef.current &&
|
||||||
|
(el.scrollTop > 200 || status !== 'LoadingMore')
|
||||||
|
) {
|
||||||
scrollAnchorRef.current = null;
|
scrollAnchorRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,7 +949,14 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
// the current distance-from-bottom into the anchor ref BEFORE
|
// the current distance-from-bottom into the anchor ref BEFORE
|
||||||
// firing loadMore so the content-change observer can pin the
|
// firing loadMore so the content-change observer can pin the
|
||||||
// viewport to the same message the reader was looking at.
|
// viewport to the same message the reader was looking at.
|
||||||
if (el.scrollTop < 80 && status === 'CanLoadMore' && !scrollAnchorRef.current) {
|
// Guard against duplicate loadMore calls while an earlier one
|
||||||
|
// is still in flight (`LoadingMore`) — Convex would de-dupe but
|
||||||
|
// it's wasted traffic.
|
||||||
|
if (
|
||||||
|
el.scrollTop < 80 &&
|
||||||
|
status === 'CanLoadMore' &&
|
||||||
|
!scrollAnchorRef.current
|
||||||
|
) {
|
||||||
scrollAnchorRef.current = {
|
scrollAnchorRef.current = {
|
||||||
bottomOffset: el.scrollHeight - el.scrollTop,
|
bottomOffset: el.scrollHeight - el.scrollTop,
|
||||||
};
|
};
|
||||||
@@ -795,14 +1025,24 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
// seen, update the ref and schedule a debounced mark-read. The
|
// seen, update the ref and schedule a debounced mark-read. The
|
||||||
// ref-only update doesn't cause re-renders — it just feeds the
|
// ref-only update doesn't cause re-renders — it just feeds the
|
||||||
// mark-read flush with an up-to-date target.
|
// mark-read flush with an up-to-date target.
|
||||||
|
//
|
||||||
|
// Only advance the "seen" mark once the newest message has actually
|
||||||
|
// decrypted — otherwise the channel's unread dot + NEW divider
|
||||||
|
// clear while the body is still rendering as blank, and users miss
|
||||||
|
// new messages they never actually saw.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
const newest = items[items.length - 1].ts;
|
const last = items[items.length - 1];
|
||||||
|
if (last.kind === 'group') {
|
||||||
|
const tail = last.group[last.group.length - 1];
|
||||||
|
if (!tail || !decryptedMap.has(tail.id)) return;
|
||||||
|
}
|
||||||
|
const newest = last.ts;
|
||||||
if (newest > latestSeenTimestampRef.current) {
|
if (newest > latestSeenTimestampRef.current) {
|
||||||
latestSeenTimestampRef.current = newest;
|
latestSeenTimestampRef.current = newest;
|
||||||
scheduleMarkRead();
|
scheduleMarkRead();
|
||||||
}
|
}
|
||||||
}, [items, scheduleMarkRead]);
|
}, [items, decryptedMap, scheduleMarkRead]);
|
||||||
|
|
||||||
// Window visibility → when the tab comes back into focus, flush
|
// Window visibility → when the tab comes back into focus, flush
|
||||||
// any pending mark-read so the sidebar dot disappears without
|
// any pending mark-read so the sidebar dot disappears without
|
||||||
@@ -845,7 +1085,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
|
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
|
||||||
<div className={styles.scroller}>
|
<div
|
||||||
|
className={styles.scroller}
|
||||||
|
style={{
|
||||||
|
// Veil: keep the list invisible until `ready` flips
|
||||||
|
// true (after decryption of the visible tail). The
|
||||||
|
// scroller is still laid out at opacity 0 so scroll
|
||||||
|
// math works — the user just doesn't see the pre-
|
||||||
|
// stabilised state that caused the visible jump.
|
||||||
|
opacity: ready ? 1 : 0,
|
||||||
|
transition: ready ? 'opacity 80ms linear' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{status === 'LoadingMore' && (
|
{status === 'LoadingMore' && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -20,10 +20,22 @@ interface PausedGifProps {
|
|||||||
onOpen?: (url: string) => void;
|
onOpen?: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache natural dimensions per URL so re-mounts (pagination, hover-in/out
|
||||||
|
// cycles in the parent, channel re-opens) start with the correct box
|
||||||
|
// instead of collapsing back to the fallback while the <img> re-decodes.
|
||||||
|
const gifDimsCache = new Map<string, { w: number; h: number }>();
|
||||||
|
|
||||||
|
// Fallback reservation for a GIF whose dimensions we haven't probed yet.
|
||||||
|
// Most Tenor/Giphy GIFs land in the 320–480px / 4:3 range, so this keeps
|
||||||
|
// the scroll-anchor math roughly right even before the real dims arrive.
|
||||||
|
const FALLBACK_DIMS = { w: 320, h: 240 } as const;
|
||||||
|
|
||||||
export function PausedGif({ url, className, onOpen }: PausedGifProps) {
|
export function PausedGif({ url, className, onOpen }: PausedGifProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
const [dims, setDims] = useState<{ w: number; h: number } | null>(
|
||||||
|
() => gifDimsCache.get(url) ?? null,
|
||||||
|
);
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
// `imgKey` is bumped every time we need to re-mount the live
|
// `imgKey` is bumped every time we need to re-mount the live
|
||||||
// <img> so the GIF starts over from frame 0 on each hover.
|
// <img> so the GIF starts over from frame 0 on each hover.
|
||||||
@@ -33,14 +45,21 @@ export function PausedGif({ url, className, onOpen }: PausedGifProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const cached = gifDimsCache.get(url);
|
||||||
|
if (cached) {
|
||||||
|
setDims(cached);
|
||||||
|
setLoaded(false);
|
||||||
|
} else {
|
||||||
setLoaded(false);
|
setLoaded(false);
|
||||||
setDims(null);
|
setDims(null);
|
||||||
|
}
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const w = img.naturalWidth || 400;
|
const w = img.naturalWidth || FALLBACK_DIMS.w;
|
||||||
const h = img.naturalHeight || 300;
|
const h = img.naturalHeight || FALLBACK_DIMS.h;
|
||||||
|
gifDimsCache.set(url, { w, h });
|
||||||
setDims({ w, h });
|
setDims({ w, h });
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
@@ -58,6 +77,12 @@ export function PausedGif({ url, className, onOpen }: PausedGifProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
|
// Nudge the scroll anchor in case the fallback box differed
|
||||||
|
// from the real natural dimensions by more than a handful of
|
||||||
|
// pixels — the parent ResizeObserver catches the wrapper
|
||||||
|
// resize, but fire the shared event too so the same-frame rAF
|
||||||
|
// batching picks it up alongside other late-loading content.
|
||||||
|
window.dispatchEvent(new CustomEvent('brycord:attachment-loaded'));
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -97,11 +122,19 @@ export function PausedGif({ url, className, onOpen }: PausedGifProps) {
|
|||||||
onOpen(url);
|
onOpen(url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={
|
style={(() => {
|
||||||
dims
|
// Always reserve a box — if we haven't probed dimensions
|
||||||
? { aspectRatio: `${dims.w} / ${dims.h}`, maxWidth: Math.min(dims.w, 400) }
|
// yet, fall back to 320×240 / 4:3 so the wrapper doesn't
|
||||||
: undefined
|
// collapse to zero height on first paint. That collapse
|
||||||
}
|
// used to be the third "jump" in the initial-load sequence
|
||||||
|
// (the wrapper snapped from 0px to ~240px the moment the
|
||||||
|
// Image().onload fired).
|
||||||
|
const d = dims ?? FALLBACK_DIMS;
|
||||||
|
return {
|
||||||
|
aspectRatio: `${d.w} / ${d.h}`,
|
||||||
|
maxWidth: Math.min(d.w, 400),
|
||||||
|
};
|
||||||
|
})()}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
* Calls `api.messages.setPinned` on confirm.
|
* Calls `api.messages.setPinned` on confirm.
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation } from 'convex/react';
|
import { useAction } from 'convex/react';
|
||||||
import { Modal } from '@discord-clone/ui';
|
import { Modal } from '@discord-clone/ui';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||||
|
import { usePlatform } from '../../platform';
|
||||||
import { PinnedMessageRow, type PinnedMessage } from './PinnedMessageRow';
|
import { PinnedMessageRow, type PinnedMessage } from './PinnedMessageRow';
|
||||||
import styles from './PinConfirmationModal.module.css';
|
import styles from './PinConfirmationModal.module.css';
|
||||||
|
|
||||||
@@ -63,7 +64,8 @@ export function PinConfirmationModal({
|
|||||||
message,
|
message,
|
||||||
variant = 'pin',
|
variant = 'pin',
|
||||||
}: PinConfirmationModalProps) {
|
}: PinConfirmationModalProps) {
|
||||||
const setPinned = useMutation(api.messages.pin);
|
const setPinned = useAction(api.messageActions.pin);
|
||||||
|
const { crypto } = usePlatform();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -71,12 +73,31 @@ export function PinConfirmationModal({
|
|||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!messageId || busy) return;
|
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);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const pinned = variant === 'pin';
|
||||||
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`pin:${messageId}:${userId}:${pinned}:${authTimestamp}`,
|
||||||
|
);
|
||||||
await setPinned({
|
await setPinned({
|
||||||
id: messageId as Id<'messages'>,
|
id: messageId as Id<'messages'>,
|
||||||
pinned: variant === 'pin',
|
userId: userId as Id<'userProfiles'>,
|
||||||
|
pinned,
|
||||||
|
authTimestamp,
|
||||||
|
authSignature,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -12,8 +12,13 @@
|
|||||||
* read-only preview.
|
* read-only preview.
|
||||||
*/
|
*/
|
||||||
import { Flag, X } from '@phosphor-icons/react';
|
import { Flag, X } from '@phosphor-icons/react';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
import { Avatar } from '@discord-clone/ui';
|
import { Avatar } from '@discord-clone/ui';
|
||||||
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
|
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
|
||||||
|
import { LinkEmbed } from './LinkEmbed';
|
||||||
|
import { MessageContent } from './MessageContent';
|
||||||
|
import { extractUrls, isGifOnlyContent } from '../../utils/messageUrls';
|
||||||
import styles from './PinnedMessageRow.module.css';
|
import styles from './PinnedMessageRow.module.css';
|
||||||
|
|
||||||
export interface PinnedMessage {
|
export interface PinnedMessage {
|
||||||
@@ -41,6 +46,14 @@ interface PinnedMessageRowProps {
|
|||||||
/** Whether the viewer has permission to unpin. Hides the X when
|
/** Whether the viewer has permission to unpin. Hides the X when
|
||||||
* they don't, even if `showHoverActions` is on. */
|
* they don't, even if `showHoverActions` is on. */
|
||||||
canUnpin?: boolean;
|
canUnpin?: boolean;
|
||||||
|
/**
|
||||||
|
* Render the message with the same link-embed / GIF-only handling
|
||||||
|
* the live chat row uses. Default false so the pins popover and
|
||||||
|
* pin confirmation modal stay as they are — opted in by the
|
||||||
|
* delete confirmation modal so the preview matches what the user
|
||||||
|
* sees in chat.
|
||||||
|
*/
|
||||||
|
showEmbeds?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
function formatTimestamp(ts: number): string {
|
||||||
@@ -65,7 +78,31 @@ export function PinnedMessageRow({
|
|||||||
onUnpin,
|
onUnpin,
|
||||||
showHoverActions = false,
|
showHoverActions = false,
|
||||||
canUnpin = true,
|
canUnpin = true,
|
||||||
|
showEmbeds = false,
|
||||||
}: PinnedMessageRowProps) {
|
}: PinnedMessageRowProps) {
|
||||||
|
// When embeds are on, lift the chat's GIF-only + extract-urls
|
||||||
|
// rules so the preview matches. Up to 3 URL previews, same cap
|
||||||
|
// MessageGroup uses.
|
||||||
|
const embedUrls =
|
||||||
|
showEmbeds && message.content ? extractUrls(message.content).slice(0, 3) : [];
|
||||||
|
const hideText =
|
||||||
|
showEmbeds && message.content ? isGifOnlyContent(message.content) : false;
|
||||||
|
|
||||||
|
// Custom emoji catalog is server-wide and small, and Convex dedupes
|
||||||
|
// identical subscriptions across components — so fetching here lets
|
||||||
|
// the popover, pin confirmation, and delete confirmation all render
|
||||||
|
// `:shortcode:` tokens as images without the caller plumbing the
|
||||||
|
// list through props.
|
||||||
|
const customEmojiDocs =
|
||||||
|
(useQuery(api.customEmojis.list, {}) ?? []) as Array<{
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
src: string;
|
||||||
|
}>;
|
||||||
|
const customEmojiList = customEmojiDocs.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
url: e.src,
|
||||||
|
}));
|
||||||
const isClickable = !showHoverActions && !!onJumpTo;
|
const isClickable = !showHoverActions && !!onJumpTo;
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -137,13 +174,30 @@ export function PinnedMessageRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{message.content ? (
|
{message.content && !hideText ? (
|
||||||
<div className={styles.content}>{message.content}</div>
|
<div className={styles.content}>
|
||||||
) : !message.attachments || message.attachments.length === 0 ? (
|
<MessageContent
|
||||||
|
content={message.content}
|
||||||
|
customEmojis={customEmojiList}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : !message.content &&
|
||||||
|
(!message.attachments || message.attachments.length === 0) &&
|
||||||
|
embedUrls.length === 0 ? (
|
||||||
<div className={`${styles.content} ${styles.undecryptable}`}>
|
<div className={`${styles.content} ${styles.undecryptable}`}>
|
||||||
(no preview)
|
(no preview)
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{embedUrls.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={styles.embeds}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{embedUrls.map((url, i) => (
|
||||||
|
<LinkEmbed key={`${message.id}-embed-${i}`} url={url} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{message.attachments && message.attachments.length > 0 && (
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className={styles.attachments}
|
className={styles.attachments}
|
||||||
|
|||||||
@@ -30,11 +30,21 @@ export function TwemojiImg({ emoji, size = 22, className }: TwemojiImgProps) {
|
|||||||
alt={emoji}
|
alt={emoji}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
loading="lazy"
|
// No `loading="lazy"` — deferred decode of a 22×22 CDN image
|
||||||
|
// produces exactly the per-emoji reflow this app is tuned to
|
||||||
|
// avoid. Eager load lets the browser pipeline them naturally.
|
||||||
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className={className}
|
className={className}
|
||||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||||
onError={() => setBroken(true)}
|
onError={() => setBroken(true)}
|
||||||
|
onLoad={() => {
|
||||||
|
// Each emoji's decode can nudge line height by a fraction
|
||||||
|
// of a pixel (subpixel rounding), which the ResizeObserver
|
||||||
|
// sometimes misses. Fire the shared attachment-loaded event
|
||||||
|
// so Messages re-pins if the user is anchored to bottom.
|
||||||
|
window.dispatchEvent(new CustomEvent('brycord:attachment-loaded'));
|
||||||
|
}}
|
||||||
data-emoji-codepoint={emojiToCodepoint(emoji)}
|
data-emoji-codepoint={emojiToCodepoint(emoji)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
* subscribers update automatically once the mutation completes.
|
* subscribers update automatically once the mutation completes.
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useAction, useQuery } from 'convex/react';
|
||||||
import { Check, X } from '@phosphor-icons/react';
|
import { Check, X } from '@phosphor-icons/react';
|
||||||
import { BottomSheet } from '@discord-clone/ui';
|
import { BottomSheet } from '@discord-clone/ui';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||||
|
import { usePlatform } from '../../platform';
|
||||||
import styles from './MobileSetStatusSheet.module.css';
|
import styles from './MobileSetStatusSheet.module.css';
|
||||||
|
|
||||||
export type UiPresence = 'online' | 'idle' | 'dnd' | 'invisible';
|
export type UiPresence = 'online' | 'idle' | 'dnd' | 'invisible';
|
||||||
@@ -59,7 +60,8 @@ export function MobileSetStatusSheet({ isOpen, onClose }: MobileSetStatusSheetPr
|
|||||||
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
||||||
const me = allUsers.find((u) => u.id === userId);
|
const me = allUsers.find((u) => u.id === userId);
|
||||||
const current = (me?.status ?? 'online') as UiPresence;
|
const current = (me?.status ?? 'online') as UiPresence;
|
||||||
const updateStatus = useMutation(api.auth.updateStatus);
|
const { crypto } = usePlatform();
|
||||||
|
const updateStatus = useAction(api.authActions.updateStatus);
|
||||||
const [busy, setBusy] = useState<UiPresence | null>(null);
|
const [busy, setBusy] = useState<UiPresence | null>(null);
|
||||||
|
|
||||||
const handleSelect = async (next: UiPresence) => {
|
const handleSelect = async (next: UiPresence) => {
|
||||||
@@ -68,9 +70,24 @@ export function MobileSetStatusSheet({ isOpen, onClose }: MobileSetStatusSheetPr
|
|||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const signingKey =
|
||||||
|
typeof sessionStorage !== 'undefined'
|
||||||
|
? sessionStorage.getItem('signingKey')
|
||||||
|
: null;
|
||||||
|
if (!signingKey) return;
|
||||||
setBusy(next);
|
setBusy(next);
|
||||||
try {
|
try {
|
||||||
await updateStatus({ userId: userId as Id<'userProfiles'>, status: next });
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`updateStatus:${userId}:${next}:${authTimestamp}`,
|
||||||
|
);
|
||||||
|
await updateStatus({
|
||||||
|
userId: userId as Id<'userProfiles'>,
|
||||||
|
status: next,
|
||||||
|
authTimestamp,
|
||||||
|
authSignature,
|
||||||
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to set presence:', err);
|
console.warn('Failed to set presence:', err);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useAction, useQuery } from 'convex/react';
|
||||||
import {
|
import {
|
||||||
Circle,
|
Circle,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
import { Avatar, Button } from '@discord-clone/ui';
|
import { Avatar, Button } from '@discord-clone/ui';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
|
import { usePlatform } from '../../platform';
|
||||||
import styles from './UserAreaProfilePopout.module.css';
|
import styles from './UserAreaProfilePopout.module.css';
|
||||||
|
|
||||||
type StatusId = 'online' | 'idle' | 'dnd' | 'invisible';
|
type StatusId = 'online' | 'idle' | 'dnd' | 'invisible';
|
||||||
@@ -141,7 +142,8 @@ export function UserAreaProfilePopout({
|
|||||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||||
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
||||||
const me = allUsers.find((u) => u.id === userId);
|
const me = allUsers.find((u) => u.id === userId);
|
||||||
const updateStatus = useMutation(api.auth.updateStatus);
|
const { crypto } = usePlatform();
|
||||||
|
const updateStatus = useAction(api.authActions.updateStatus);
|
||||||
|
|
||||||
// Close on click-outside / Escape.
|
// Close on click-outside / Escape.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -188,8 +190,26 @@ export function UserAreaProfilePopout({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusSelect = async (status: StatusId) => {
|
const handleStatusSelect = async (status: StatusId) => {
|
||||||
|
const signingKey =
|
||||||
|
typeof sessionStorage !== 'undefined'
|
||||||
|
? sessionStorage.getItem('signingKey')
|
||||||
|
: null;
|
||||||
|
if (!signingKey) {
|
||||||
|
setStatusPickerOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await updateStatus({ userId: userId as any, status });
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`updateStatus:${userId}:${status}:${authTimestamp}`,
|
||||||
|
);
|
||||||
|
await updateStatus({
|
||||||
|
userId: userId as any,
|
||||||
|
status,
|
||||||
|
authTimestamp,
|
||||||
|
authSignature,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update status:', err);
|
console.error('Failed to update status:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
import { Avatar, Button, Modal } from '@discord-clone/ui';
|
import { Avatar, Button, Modal } from '@discord-clone/ui';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||||
|
import { usePlatform } from '../../platform';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import { useOnlineUsers } from '../../contexts/PresenceContext';
|
import { useOnlineUsers } from '../../contexts/PresenceContext';
|
||||||
import { MobileMemberProfileSheet } from './MobileMemberProfileSheet';
|
import { MobileMemberProfileSheet } from './MobileMemberProfileSheet';
|
||||||
@@ -122,7 +123,8 @@ export function MemberListContainer({ channelId, variant = 'panel' }: MemberList
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY, member });
|
setContextMenu({ x: e.clientX, y: e.clientY, member });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setNickname = useMutation(api.auth.setNickname);
|
const setNickname = useAction(api.authActions.setNickname);
|
||||||
|
const { crypto } = usePlatform();
|
||||||
|
|
||||||
const openNicknameEditor = (member: MemberRow) => {
|
const openNicknameEditor = (member: MemberRow) => {
|
||||||
setNicknameTarget(member);
|
setNicknameTarget(member);
|
||||||
@@ -147,13 +149,28 @@ export function MemberListContainer({ channelId, variant = 'panel' }: MemberList
|
|||||||
? localStorage.getItem('userId')
|
? localStorage.getItem('userId')
|
||||||
: null;
|
: null;
|
||||||
if (!localUserId) return;
|
if (!localUserId) return;
|
||||||
|
const signingKey =
|
||||||
|
typeof sessionStorage !== 'undefined'
|
||||||
|
? sessionStorage.getItem('signingKey')
|
||||||
|
: null;
|
||||||
|
if (!signingKey) {
|
||||||
|
setNicknameError('Not signed in');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setNicknameSaving(true);
|
setNicknameSaving(true);
|
||||||
setNicknameError(null);
|
setNicknameError(null);
|
||||||
try {
|
try {
|
||||||
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`setNickname:${localUserId}:${nicknameTarget.userId}:${authTimestamp}`,
|
||||||
|
);
|
||||||
await setNickname({
|
await setNickname({
|
||||||
actorUserId: localUserId as any,
|
actorUserId: localUserId as any,
|
||||||
targetUserId: nicknameTarget.userId as any,
|
targetUserId: nicknameTarget.userId as any,
|
||||||
displayName: nicknameDraft.trim(),
|
displayName: nicknameDraft.trim(),
|
||||||
|
authTimestamp,
|
||||||
|
authSignature,
|
||||||
});
|
});
|
||||||
setNicknameTarget(null);
|
setNicknameTarget(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
@@ -171,7 +171,30 @@ export function AccountTab() {
|
|||||||
const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||||
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
|
||||||
const me = allUsers.find((u) => u.id === userId);
|
const me = allUsers.find((u) => u.id === userId);
|
||||||
const updateProfile = useMutation(api.auth.updateProfile);
|
const { crypto } = usePlatform();
|
||||||
|
const updateProfileAction = useAction(api.authActions.updateProfile);
|
||||||
|
// Wraps the signed action so the three call sites below stay readable.
|
||||||
|
// Signs `updateProfile:${userId}:${authTimestamp}` with the session's
|
||||||
|
// Ed25519 key so the server can prove the caller controls `userId`.
|
||||||
|
const updateProfile = async (
|
||||||
|
patch: Record<string, unknown> & { userId: string },
|
||||||
|
) => {
|
||||||
|
const signingKey =
|
||||||
|
typeof sessionStorage !== 'undefined'
|
||||||
|
? sessionStorage.getItem('signingKey')
|
||||||
|
: null;
|
||||||
|
if (!signingKey) throw new Error('Not signed in');
|
||||||
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`updateProfile:${patch.userId}:${authTimestamp}`,
|
||||||
|
);
|
||||||
|
return updateProfileAction({
|
||||||
|
...patch,
|
||||||
|
authTimestamp,
|
||||||
|
authSignature,
|
||||||
|
} as any);
|
||||||
|
};
|
||||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||||
const validateUpload = useMutation(api.files.validateUpload);
|
const validateUpload = useMutation(api.files.validateUpload);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { Room, RoomEvent, VideoPresets, ConnectionQuality, DisconnectReason } from 'livekit-client';
|
import { Room, RoomEvent, VideoPresets, ConnectionQuality, DisconnectReason } from 'livekit-client';
|
||||||
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
|
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
|
||||||
import { useQuery, useConvex } from 'convex/react';
|
import { useQuery, useConvex } from 'convex/react';
|
||||||
@@ -99,13 +99,42 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
|
// Single source of truth for the signed-in user id. All the
|
||||||
|
// voice/presence effects below used to read localStorage
|
||||||
|
// directly — that worked but wasn't reactive, so a
|
||||||
|
// logout-then-login in the same tab could leave effects
|
||||||
|
// operating on a stale id until something else triggered a
|
||||||
|
// rerender. The `storage` and `brycord:auth-change` listeners
|
||||||
|
// keep this state in sync across tabs (the former) and
|
||||||
|
// within-tab login/logout (the latter, emitted by
|
||||||
|
// hooks/useLogout + the login page).
|
||||||
|
const [myUserId, setMyUserId] = useState(
|
||||||
|
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const sync = () => {
|
||||||
|
setMyUserId(
|
||||||
|
typeof localStorage !== 'undefined'
|
||||||
|
? localStorage.getItem('userId')
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', sync);
|
||||||
|
window.addEventListener('brycord:auth-change', sync);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', sync);
|
||||||
|
window.removeEventListener('brycord:auth-change', sync);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
|
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
|
||||||
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
|
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
|
||||||
|
|
||||||
const setWatchingStreamOf = useCallback((identity) => {
|
const setWatchingStreamOf = useCallback((identity) => {
|
||||||
setWatchingStreamOfRaw(identity);
|
setWatchingStreamOfRaw(identity);
|
||||||
// Sync to backend
|
// Sync to backend
|
||||||
const userId = localStorage.getItem('userId');
|
const userId = myUserId;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
convex.mutation(api.voiceState.setWatchingStream, {
|
convex.mutation(api.voiceState.setWatchingStream, {
|
||||||
userId,
|
userId,
|
||||||
@@ -120,7 +149,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
|
|
||||||
const clearWatchingStream = useCallback(() => {
|
const clearWatchingStream = useCallback(() => {
|
||||||
setWatchingStreamOfRaw(null);
|
setWatchingStreamOfRaw(null);
|
||||||
const userId = localStorage.getItem('userId');
|
const userId = myUserId;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
convex.mutation(api.voiceState.setWatchingStream, { userId }).catch(
|
convex.mutation(api.voiceState.setWatchingStream, { userId }).catch(
|
||||||
e => console.error('Failed to clear watching stream:', e)
|
e => console.error('Failed to clear watching stream:', e)
|
||||||
@@ -205,7 +234,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);
|
const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);
|
||||||
|
|
||||||
const serverMute = async (targetUserId, isServerMuted) => {
|
const serverMute = async (targetUserId, isServerMuted) => {
|
||||||
const actorUserId = localStorage.getItem('userId');
|
const actorUserId = myUserId;
|
||||||
if (!actorUserId) return;
|
if (!actorUserId) return;
|
||||||
try {
|
try {
|
||||||
await convex.mutation(api.voiceState.serverMute, { actorUserId, targetUserId, isServerMuted });
|
await convex.mutation(api.voiceState.serverMute, { actorUserId, targetUserId, isServerMuted });
|
||||||
@@ -215,7 +244,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const disconnectUser = async (targetUserId) => {
|
const disconnectUser = async (targetUserId) => {
|
||||||
const actorUserId = localStorage.getItem('userId');
|
const actorUserId = myUserId;
|
||||||
if (!actorUserId) return;
|
if (!actorUserId) return;
|
||||||
try {
|
try {
|
||||||
await convex.mutation(api.voiceState.disconnectUser, { actorUserId, targetUserId });
|
await convex.mutation(api.voiceState.disconnectUser, { actorUserId, targetUserId });
|
||||||
@@ -236,7 +265,6 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const serverSettings = useQuery(api.serverSettings.get);
|
const serverSettings = useQuery(api.serverSettings.get);
|
||||||
|
|
||||||
// Subscribe to own join sound URL for self-join playback
|
// Subscribe to own join sound URL for self-join playback
|
||||||
const myUserId = localStorage.getItem('userId');
|
|
||||||
const myJoinSoundUrl = useQuery(
|
const myJoinSoundUrl = useQuery(
|
||||||
api.auth.getMyJoinSoundUrl,
|
api.auth.getMyJoinSoundUrl,
|
||||||
myUserId ? { userId: myUserId } : "skip"
|
myUserId ? { userId: myUserId } : "skip"
|
||||||
@@ -248,7 +276,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
|
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
|
||||||
|
|
||||||
async function updateVoiceState(fields) {
|
async function updateVoiceState(fields) {
|
||||||
const userId = localStorage.getItem('userId');
|
const userId = myUserId;
|
||||||
if (!userId || !activeChannelId) return;
|
if (!userId || !activeChannelId) return;
|
||||||
try {
|
try {
|
||||||
await convex.mutation(api.voiceState.updateState, { userId, ...fields });
|
await convex.mutation(api.voiceState.updateState, { userId, ...fields });
|
||||||
@@ -280,12 +308,36 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token: lkToken } = await convex.action(api.voice.getToken, {
|
// Prove we control `userId` by signing the (userId, channelId,
|
||||||
|
// timestamp) tuple with the Ed25519 key decrypted at login.
|
||||||
|
// The server verifies with our public signing key before minting
|
||||||
|
// a LiveKit JWT, so voice rooms can't be joined by forging args.
|
||||||
|
const signingKey = sessionStorage.getItem('signingKey');
|
||||||
|
if (!signingKey) {
|
||||||
|
console.error('Missing signing key — cannot authorize voice join');
|
||||||
|
setConnectionState('error');
|
||||||
|
setActiveChannelId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const message = `voice-token:${userId}:${channelId}:${timestamp}`;
|
||||||
|
const signature = await platform.crypto.signMessage(signingKey, message);
|
||||||
|
|
||||||
|
const tokenResult = await convex.action(api.voice.getToken, {
|
||||||
channelId,
|
channelId,
|
||||||
userId,
|
userId,
|
||||||
username: localStorage.getItem('username') || 'Unknown'
|
timestamp,
|
||||||
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ('error' in tokenResult) {
|
||||||
|
console.error('Voice token rejected:', tokenResult.error);
|
||||||
|
setConnectionState('error');
|
||||||
|
setActiveChannelId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lkToken = tokenResult.token;
|
||||||
if (!lkToken) throw new Error('Failed to get token');
|
if (!lkToken) throw new Error('Failed to get token');
|
||||||
|
|
||||||
setToken(lkToken);
|
setToken(lkToken);
|
||||||
@@ -515,7 +567,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
// Heartbeat: send periodic heartbeat to prevent ghost voice states
|
// Heartbeat: send periodic heartbeat to prevent ghost voice states
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeChannelId) return;
|
if (!activeChannelId) return;
|
||||||
const userId = localStorage.getItem('userId');
|
const userId = myUserId;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
const sendHeartbeat = () => {
|
const sendHeartbeat = () => {
|
||||||
@@ -530,10 +582,17 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [activeChannelId, convex]);
|
}, [activeChannelId, convex]);
|
||||||
|
|
||||||
// Handle notification action buttons (Android foreground service)
|
// Handle notification action buttons (Android foreground service).
|
||||||
|
// Capacitor's plugin API has historically shifted between returning
|
||||||
|
// a listener handle synchronously and returning a Promise, so we
|
||||||
|
// normalize both shapes into the same cleanup code instead of
|
||||||
|
// leaving a silent no-op when neither matches.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!voiceService) return;
|
if (!voiceService) return;
|
||||||
const listener = voiceService.addNotificationActionListener((event) => {
|
let cancelled = false;
|
||||||
|
let resolvedHandle = null;
|
||||||
|
|
||||||
|
const handle = voiceService.addNotificationActionListener((event) => {
|
||||||
switch (event.action) {
|
switch (event.action) {
|
||||||
case 'disconnect':
|
case 'disconnect':
|
||||||
disconnectVoice();
|
disconnectVoice();
|
||||||
@@ -546,17 +605,43 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (handle && typeof handle.then === 'function') {
|
||||||
|
handle.then((l) => {
|
||||||
|
if (cancelled) {
|
||||||
|
l?.remove?.();
|
||||||
|
} else {
|
||||||
|
resolvedHandle = l;
|
||||||
|
}
|
||||||
|
}).catch(() => { /* nothing to clean up */ });
|
||||||
|
} else {
|
||||||
|
resolvedHandle = handle;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (listener && listener.remove) listener.remove();
|
cancelled = true;
|
||||||
else if (listener && typeof listener.then === 'function') {
|
try {
|
||||||
listener.then(l => l?.remove?.());
|
resolvedHandle?.remove?.();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to remove notification listener:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [voiceService, activeChannelId]);
|
}, [voiceService, activeChannelId]);
|
||||||
|
|
||||||
// Detect when another user moves us to a different voice channel
|
// Detect when another user moves us to a different voice channel.
|
||||||
|
//
|
||||||
|
// `connectToVoice` is a plain function (not useCallback), so listing
|
||||||
|
// it as a dep would re-run this effect on every render — an infinite
|
||||||
|
// reconnect loop. We stash the latest reference in a ref so the
|
||||||
|
// effect can call the freshest copy without triggering itself. The
|
||||||
|
// audit flagged the old setup for potentially using stale
|
||||||
|
// credentials if the move fired mid-render; the ref closes that gap.
|
||||||
|
const connectToVoiceRef = useRef(connectToVoice);
|
||||||
|
useEffect(() => {
|
||||||
|
connectToVoiceRef.current = connectToVoice;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const myUserId = localStorage.getItem('userId');
|
|
||||||
if (!myUserId || !activeChannelId || isMovingRef.current) return;
|
if (!myUserId || !activeChannelId || isMovingRef.current) return;
|
||||||
|
|
||||||
// Find which channel the server says we're in
|
// Find which channel the server says we're in
|
||||||
@@ -571,11 +656,12 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
// If server says we're in a different channel, reconnect
|
// If server says we're in a different channel, reconnect
|
||||||
if (serverChannelId && serverChannelId !== activeChannelId) {
|
if (serverChannelId && serverChannelId !== activeChannelId) {
|
||||||
isMovingRef.current = true;
|
isMovingRef.current = true;
|
||||||
|
const currentRoom = room;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const channel = await convex.query(api.channels.get, { id: serverChannelId });
|
const channel = await convex.query(api.channels.get, { id: serverChannelId });
|
||||||
if (room) await room.disconnect();
|
if (currentRoom) await currentRoom.disconnect();
|
||||||
await connectToVoice(serverChannelId, channel?.name || 'Voice', myUserId);
|
await connectToVoiceRef.current(serverChannelId, channel?.name || 'Voice', myUserId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to reconnect after move:', e);
|
console.error('Failed to reconnect after move:', e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -583,18 +669,17 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}, [voiceStates, activeChannelId]);
|
}, [voiceStates, activeChannelId, room, convex, myUserId]);
|
||||||
|
|
||||||
// Enforce server mute: force-disable mic when server muted, restore when lifted
|
// Enforce server mute: force-disable mic when server muted, restore when lifted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const myUserId = localStorage.getItem('userId');
|
|
||||||
if (!myUserId || !room) return;
|
if (!myUserId || !room) return;
|
||||||
if (isServerMuted(myUserId)) {
|
if (isServerMuted(myUserId)) {
|
||||||
room.localParticipant.setMicrophoneEnabled(false);
|
room.localParticipant.setMicrophoneEnabled(false);
|
||||||
} else if (!isMuted && !isDeafened) {
|
} else if (!isMuted && !isDeafened) {
|
||||||
room.localParticipant.setMicrophoneEnabled(true);
|
room.localParticipant.setMicrophoneEnabled(true);
|
||||||
}
|
}
|
||||||
}, [voiceStates, room]);
|
}, [voiceStates, room, myUserId]);
|
||||||
|
|
||||||
// Re-apply personal mutes/volumes when room or participants change
|
// Re-apply personal mutes/volumes when room or participants change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -637,12 +722,25 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (idleSeconds >= afkTimeout) {
|
if (idleSeconds >= afkTimeout) {
|
||||||
const userId = localStorage.getItem('userId');
|
const userId = myUserId;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
// On Capacitor, also set user status to idle
|
// On Capacitor, also set user status to idle
|
||||||
if (isCapacitor) {
|
if (isCapacitor) {
|
||||||
await convex.mutation(api.auth.updateStatus, { userId, status: 'idle' });
|
const signingKey = sessionStorage.getItem('signingKey');
|
||||||
|
if (signingKey) {
|
||||||
|
const authTimestamp = Date.now();
|
||||||
|
const authSignature = await platform.crypto.signMessage(
|
||||||
|
signingKey,
|
||||||
|
`updateStatus:${userId}:idle:${authTimestamp}`,
|
||||||
|
);
|
||||||
|
await convex.action(api.authActions.updateStatus, {
|
||||||
|
userId,
|
||||||
|
status: 'idle',
|
||||||
|
authTimestamp,
|
||||||
|
authSignature,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await convex.mutation(api.voiceState.afkMove, {
|
await convex.mutation(api.voiceState.afkMove, {
|
||||||
@@ -670,7 +768,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selfId = localStorage.getItem('userId');
|
const selfId = myUserId;
|
||||||
const channelUsers = voiceStates[activeChannelId] || [];
|
const channelUsers = voiceStates[activeChannelId] || [];
|
||||||
const currentUserIds = new Set(channelUsers.map(u => u.userId));
|
const currentUserIds = new Set(channelUsers.map(u => u.userId));
|
||||||
|
|
||||||
@@ -702,7 +800,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevChannelUsersRef.current = currentUserIds;
|
prevChannelUsersRef.current = currentUserIds;
|
||||||
}, [voiceStates, activeChannelId]);
|
}, [voiceStates, activeChannelId, myUserId]);
|
||||||
|
|
||||||
// Manage screen share subscriptions — only subscribe when actively watching
|
// Manage screen share subscriptions — only subscribe when actively watching
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -794,7 +892,6 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const myUserId = localStorage.getItem('userId');
|
|
||||||
// Collect all users currently watching the same stream
|
// Collect all users currently watching the same stream
|
||||||
const currentViewers = new Set();
|
const currentViewers = new Set();
|
||||||
for (const users of Object.values(voiceStates)) {
|
for (const users of Object.values(voiceStates)) {
|
||||||
@@ -831,7 +928,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevViewersRef.current = currentViewers;
|
prevViewersRef.current = currentViewers;
|
||||||
}, [voiceStates, watchingStreamOf]);
|
}, [voiceStates, watchingStreamOf, myUserId]);
|
||||||
|
|
||||||
// Detect screen-share publications starting / stopping across the
|
// Detect screen-share publications starting / stopping across the
|
||||||
// active voice channel (including the local user) and play a
|
// active voice channel (including the local user) and play a
|
||||||
@@ -884,29 +981,54 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleMute = async () => {
|
const toggleMute = async () => {
|
||||||
const myUserId = localStorage.getItem('userId');
|
|
||||||
// Block unmute if server muted or in AFK channel
|
// Block unmute if server muted or in AFK channel
|
||||||
if (isMuted && myUserId && isServerMuted(myUserId)) return;
|
if (isMuted && myUserId && isServerMuted(myUserId)) return;
|
||||||
if (isMuted && isInAfkChannel) return;
|
if (isMuted && isInAfkChannel) return;
|
||||||
const nextState = !isMuted;
|
const nextState = !isMuted;
|
||||||
|
// Flip LiveKit first. If this rejects we bail before committing any
|
||||||
|
// UI state — otherwise the user sees "muted" while their mic is
|
||||||
|
// still publishing to everyone in the room (a privacy leak, not
|
||||||
|
// just a UX nit).
|
||||||
|
if (room) {
|
||||||
|
try {
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to toggle microphone:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsMuted(nextState);
|
setIsMuted(nextState);
|
||||||
playSound(nextState ? 'mute' : 'unmute');
|
playSound(nextState ? 'mute' : 'unmute');
|
||||||
voiceService?.updateNotification({ isMuted: nextState });
|
voiceService?.updateNotification({ isMuted: nextState });
|
||||||
if (room) {
|
try {
|
||||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
|
||||||
}
|
|
||||||
await updateVoiceState({ isMuted: nextState });
|
await updateVoiceState({ isMuted: nextState });
|
||||||
|
} catch (e) {
|
||||||
|
// LiveKit is already in the right state, so audio is safe;
|
||||||
|
// the server's voice-states row is just stale. Other clients
|
||||||
|
// will pick up the correct value from our next successful
|
||||||
|
// mutation or heartbeat. Log and move on.
|
||||||
|
console.error('Failed to sync mute state to server:', e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDeafen = async () => {
|
const toggleDeafen = async () => {
|
||||||
const nextState = !isDeafened;
|
const nextState = !isDeafened;
|
||||||
|
if (room && !isMuted) {
|
||||||
|
try {
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to toggle microphone for deafen:', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsDeafened(nextState);
|
setIsDeafened(nextState);
|
||||||
playSound(nextState ? 'deafen' : 'undeafen');
|
playSound(nextState ? 'deafen' : 'undeafen');
|
||||||
voiceService?.updateNotification({ isDeafened: nextState });
|
voiceService?.updateNotification({ isDeafened: nextState });
|
||||||
if (room && !isMuted) {
|
try {
|
||||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
|
||||||
}
|
|
||||||
await updateVoiceState({ isDeafened: nextState });
|
await updateVoiceState({ isDeafened: nextState });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sync deafen state to server:', e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actually flip the LiveKit screen-share publication on/off. The
|
// Actually flip the LiveKit screen-share publication on/off. The
|
||||||
@@ -917,15 +1039,32 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
// resulting track, and tears it down on false.
|
// resulting track, and tears it down on false.
|
||||||
const setScreenSharing = async (active) => {
|
const setScreenSharing = async (active) => {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
// Snapshot the screen-share publications *before* disabling so we
|
||||||
|
// can explicitly stop the underlying MediaStreamTracks afterwards.
|
||||||
|
// LiveKit's `setScreenShareEnabled(false)` unpublishes but doesn't
|
||||||
|
// always fully release the getDisplayMedia tracks before returning,
|
||||||
|
// which caused intermittent "NotAllowedError: Permission denied"
|
||||||
|
// when the user re-shared immediately.
|
||||||
|
const toStop = [];
|
||||||
|
if (!active) {
|
||||||
|
const pubs = room.localParticipant?.trackPublications;
|
||||||
|
if (pubs?.forEach) {
|
||||||
|
pubs.forEach((pub) => {
|
||||||
|
const src = pub.source ?? pub.track?.source;
|
||||||
|
if (src === 'screen_share' || src === 'screen_share_audio') {
|
||||||
|
if (pub.track?.mediaStreamTrack) {
|
||||||
|
toStop.push(pub.track.mediaStreamTrack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await room.localParticipant.setScreenShareEnabled(active, {
|
await room.localParticipant.setScreenShareEnabled(active, {
|
||||||
audio: true,
|
audio: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to toggle screen share:', e);
|
console.warn('Failed to toggle screen share:', e);
|
||||||
// User cancelled the picker or permission was denied —
|
|
||||||
// keep local state in sync with whatever actually happened
|
|
||||||
// on the LiveKit side.
|
|
||||||
const published = !!room.localParticipant.getTrackPublication?.(
|
const published = !!room.localParticipant.getTrackPublication?.(
|
||||||
'screen_share',
|
'screen_share',
|
||||||
);
|
);
|
||||||
@@ -933,6 +1072,11 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
await updateVoiceState({ isScreenSharing: published });
|
await updateVoiceState({ isScreenSharing: published });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!active) {
|
||||||
|
for (const mst of toStop) {
|
||||||
|
try { mst.stop(); } catch { /* already stopped */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsScreenSharingLocal(active);
|
setIsScreenSharingLocal(active);
|
||||||
await updateVoiceState({ isScreenSharing: active });
|
await updateVoiceState({ isScreenSharing: active });
|
||||||
};
|
};
|
||||||
@@ -1054,8 +1198,16 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
}, [room, stopRecording]);
|
}, [room, stopRecording]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
// Stable callback so the Provider value doesn't churn on a fresh arrow
|
||||||
<VoiceContext.Provider value={{
|
// function every render.
|
||||||
|
const clearRecordingError = useCallback(() => setRecordingError(null), []);
|
||||||
|
|
||||||
|
// Memoize the provider value so components that subscribe via
|
||||||
|
// `useVoice()` don't re-render on every parent render. Inline object
|
||||||
|
// literals caused every voice-aware component (sidebar, chat header,
|
||||||
|
// user tiles, voice bar) to re-render whenever *anything* upstream
|
||||||
|
// changed — a huge perf hit during active voice sessions.
|
||||||
|
const value = useMemo(() => ({
|
||||||
activeChannelId,
|
activeChannelId,
|
||||||
activeChannelName,
|
activeChannelName,
|
||||||
connectionState,
|
connectionState,
|
||||||
@@ -1093,15 +1245,62 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
isReceivingScreenShareAudio,
|
isReceivingScreenShareAudio,
|
||||||
isReconnecting,
|
isReconnecting,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
// Voice recording
|
|
||||||
isRecording,
|
isRecording,
|
||||||
recordingStartedAt,
|
recordingStartedAt,
|
||||||
recordingSessionId,
|
recordingSessionId,
|
||||||
recordingError,
|
recordingError,
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
clearRecordingError: () => setRecordingError(null),
|
clearRecordingError,
|
||||||
}}>
|
}), [
|
||||||
|
activeChannelId,
|
||||||
|
activeChannelName,
|
||||||
|
connectionState,
|
||||||
|
connectToVoice,
|
||||||
|
disconnectVoice,
|
||||||
|
room,
|
||||||
|
token,
|
||||||
|
voiceStates,
|
||||||
|
activeSpeakers,
|
||||||
|
isMuted,
|
||||||
|
isDeafened,
|
||||||
|
toggleMute,
|
||||||
|
toggleDeafen,
|
||||||
|
isScreenSharing,
|
||||||
|
setScreenSharing,
|
||||||
|
isCameraOn,
|
||||||
|
setCamera,
|
||||||
|
toggleCamera,
|
||||||
|
personallyMutedUsers,
|
||||||
|
togglePersonalMute,
|
||||||
|
isPersonallyMuted,
|
||||||
|
userVolumes,
|
||||||
|
setUserVolume,
|
||||||
|
getUserVolume,
|
||||||
|
serverMute,
|
||||||
|
disconnectUser,
|
||||||
|
isServerMuted,
|
||||||
|
isInAfkChannel,
|
||||||
|
serverSettings,
|
||||||
|
watchingStreamOf,
|
||||||
|
setWatchingStreamOf,
|
||||||
|
switchDevice,
|
||||||
|
globalOutputVolume,
|
||||||
|
setGlobalOutputVolume,
|
||||||
|
isReceivingScreenShareAudio,
|
||||||
|
isReconnecting,
|
||||||
|
connectionQualities,
|
||||||
|
isRecording,
|
||||||
|
recordingStartedAt,
|
||||||
|
recordingSessionId,
|
||||||
|
recordingError,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
clearRecordingError,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VoiceContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
{room && (
|
{room && (
|
||||||
<LiveKitRoom
|
<LiveKitRoom
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { usePlatform } from '../platform';
|
import { usePlatform } from '../platform';
|
||||||
|
import { clearDecryptionCache } from '../components/channel/Messages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useLogout — wipes all client-side auth state and hard-reloads the
|
* useLogout — wipes all client-side auth state and hard-reloads the
|
||||||
@@ -16,6 +17,16 @@ export function useLogout(): () => Promise<void> {
|
|||||||
const { session } = usePlatform();
|
const { session } = usePlatform();
|
||||||
|
|
||||||
return useCallback(async () => {
|
return useCallback(async () => {
|
||||||
|
// Flush in-memory plaintext before anything else so a failure in a
|
||||||
|
// later step (e.g. platform session clear throws, reload never
|
||||||
|
// fires) still wipes decrypted message content from this tab's
|
||||||
|
// heap. The hard reload below normally covers this already.
|
||||||
|
try {
|
||||||
|
clearDecryptionCache();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to clear decryption cache:', err);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await session?.clear?.();
|
await session?.clear?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
* @property {(data: string) => Promise<string>} sha256 - Returns hex hash
|
* @property {(data: string) => Promise<string>} sha256 - Returns hex hash
|
||||||
* @property {(privateKey: string, message: string) => Promise<string>} signMessage
|
* @property {(privateKey: string, message: string) => Promise<string>} signMessage
|
||||||
* @property {(publicKey: string, message: string, signature: string) => Promise<boolean>} verifySignature
|
* @property {(publicKey: string, message: string, signature: string) => Promise<boolean>} verifySignature
|
||||||
* @property {(password: string, salt: string) => Promise<{dek: string, dak: string}>} deriveAuthKeys
|
* @property {(password: string, salt: string) => Promise<{dek: Uint8Array, dak: string}>} deriveAuthKeys - `dak` is a hex string, `dek` is raw bytes (Uint8Array on web, Buffer on Electron — both accepted by encryptData)
|
||||||
* @property {(data: string, key: string) => Promise<{content: string, iv: string, tag: string}>} encryptData
|
* @property {(data: string, key: string | Uint8Array) => Promise<{content: string, iv: string, tag: string}>} encryptData
|
||||||
* @property {(encryptedData: string, key: string, iv: string, tag: string, options?: object) => Promise<string>} decryptData
|
* @property {(encryptedData: string, key: string | Uint8Array, iv: string, tag: string, options?: object) => Promise<string>} decryptData
|
||||||
* @property {(items: Array) => Promise<Array>} decryptBatch
|
* @property {(items: Array) => Promise<Array>} decryptBatch
|
||||||
* @property {(items: Array) => Promise<Array>} verifyBatch
|
* @property {(items: Array) => Promise<Array>} verifyBatch
|
||||||
* @property {(publicKey: string, data: string) => Promise<string>} publicEncrypt
|
* @property {(publicKey: string, data: string) => Promise<string>} publicEncrypt
|
||||||
|
|||||||
33
packages/shared/src/utils/messageUrls.ts
Normal file
33
packages/shared/src/utils/messageUrls.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Message URL helpers shared between the live chat row
|
||||||
|
* (`MessageGroup`) and the confirmation-modal preview card
|
||||||
|
* (`PinnedMessageRow`'s `showEmbeds` mode). Keeping them here means
|
||||||
|
* the "which URLs count as embed-worthy" rule stays consistent
|
||||||
|
* wherever a message is rendered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
|
||||||
|
|
||||||
|
/** All distinct http(s) URLs in `text`, with trailing punctuation
|
||||||
|
* like `),.;!?` stripped — those almost never belong to the URL
|
||||||
|
* but commonly butt up against one in prose. */
|
||||||
|
export function extractUrls(text: string): string[] {
|
||||||
|
const matches = text.match(URL_REGEX) ?? [];
|
||||||
|
const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
|
||||||
|
return Array.from(new Set(cleaned));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a message body is entirely made up of one or more GIF
|
||||||
|
* URLs plus whitespace — i.e. the user posted a GIF from the
|
||||||
|
* picker and there's nothing worth showing as text. Callers hide
|
||||||
|
* the text block in that case so only the embed renders. */
|
||||||
|
export function isGifOnlyContent(text: string): boolean {
|
||||||
|
const urls = extractUrls(text);
|
||||||
|
if (urls.length === 0) return false;
|
||||||
|
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
|
||||||
|
let remainder = text;
|
||||||
|
for (const u of urls) {
|
||||||
|
remainder = remainder.split(u).join('');
|
||||||
|
}
|
||||||
|
return remainder.trim().length === 0;
|
||||||
|
}
|
||||||
@@ -1,27 +1,90 @@
|
|||||||
|
// In-memory preferences cache per userId. Sitting in front of localStorage
|
||||||
|
// prevents the read-modify-write race that used to lose settings when two
|
||||||
|
// rapid calls both read the same stale blob and one overwrote the other.
|
||||||
|
// Writes now merge into this cache and flush synchronously to localStorage.
|
||||||
|
const memoryCache = new Map();
|
||||||
|
|
||||||
|
function storageKey(userId) {
|
||||||
|
return `userPrefs_${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromStorage(userId) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey(userId));
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrefs(userId) {
|
||||||
|
const cached = memoryCache.get(userId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const fresh = loadFromStorage(userId);
|
||||||
|
memoryCache.set(userId, fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserPref(userId, key, defaultValue) {
|
export function getUserPref(userId, key, defaultValue) {
|
||||||
if (!userId) return defaultValue;
|
if (!userId) return defaultValue;
|
||||||
try {
|
const prefs = getPrefs(userId);
|
||||||
const raw = localStorage.getItem(`userPrefs_${userId}`);
|
|
||||||
if (!raw) return defaultValue;
|
|
||||||
const prefs = JSON.parse(raw);
|
|
||||||
return prefs[key] !== undefined ? prefs[key] : defaultValue;
|
return prefs[key] !== undefined ? prefs[key] : defaultValue;
|
||||||
} catch {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setUserPref(userId, key, value, settings) {
|
export function setUserPref(userId, key, value, settings) {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
// Mutate the in-memory copy first so a concurrent setUserPref reads
|
||||||
const raw = localStorage.getItem(`userPrefs_${userId}`);
|
// our update instead of the stale localStorage blob. Flushing to
|
||||||
const prefs = raw ? JSON.parse(raw) : {};
|
// localStorage happens after so a QuotaExceededError doesn't roll
|
||||||
|
// back the in-memory state.
|
||||||
|
const prefs = getPrefs(userId);
|
||||||
prefs[key] = value;
|
prefs[key] = value;
|
||||||
localStorage.setItem(`userPrefs_${userId}`, JSON.stringify(prefs));
|
try {
|
||||||
// Also persist to disk via platform settings (fire-and-forget)
|
localStorage.setItem(storageKey(userId), JSON.stringify(prefs));
|
||||||
|
} catch {
|
||||||
|
// Quota / serialization failure — the in-memory cache still has
|
||||||
|
// the new value so this session stays consistent. Settings persist
|
||||||
|
// fallback handles disk.
|
||||||
|
}
|
||||||
if (settings) {
|
if (settings) {
|
||||||
settings.set(`userPrefs_${userId}`, prefs);
|
// Fire-and-forget disk persistence via platform settings. Clone
|
||||||
|
// the blob so the platform layer can't mutate our cached ref.
|
||||||
|
// `settings.set` can reject on quota overflow — swallow here so
|
||||||
|
// an unhandled rejection doesn't pollute the console, but let the
|
||||||
|
// localStorage write above still take effect.
|
||||||
|
try {
|
||||||
|
const p = settings.set(storageKey(userId), { ...prefs });
|
||||||
|
if (p && typeof p.catch === 'function') {
|
||||||
|
p.catch(() => { /* platform persistence is best-effort */ });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail on corrupt data or full storage
|
/* platform settings sync failure is non-fatal */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-tab sync: another tab writing to the same userPrefs_<userId>
|
||||||
|
// fires a `storage` event here. Drop the stale cache entry so the next
|
||||||
|
// read picks up the freshly-written value.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('storage', (e) => {
|
||||||
|
if (!e.key || !e.key.startsWith('userPrefs_')) return;
|
||||||
|
const userId = e.key.slice('userPrefs_'.length);
|
||||||
|
if (e.newValue === null) {
|
||||||
|
memoryCache.delete(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.newValue);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
memoryCache.set(userId, parsed);
|
||||||
|
} else {
|
||||||
|
memoryCache.delete(userId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
memoryCache.delete(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user