feat: Introduce comprehensive user settings, voice, chat, and screen sharing features with new components, contexts, icons, and Convex backend integrations.
All checks were successful
Build and Release / build-and-release (push) Successful in 13m55s
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"description": "Discord Clone - Electron app",
|
"description": "Discord Clone - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -224,6 +224,109 @@ export const fetchBulkPage = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listAround = query({
|
||||||
|
args: {
|
||||||
|
channelId: v.id("channels"),
|
||||||
|
messageId: v.id("messages"),
|
||||||
|
userId: v.optional(v.id("userProfiles")),
|
||||||
|
},
|
||||||
|
returns: v.any(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const target = await ctx.db.get(args.messageId);
|
||||||
|
if (!target || target.channelId !== args.channelId) {
|
||||||
|
return { messages: [], hasOlder: false, hasNewer: false, targetFound: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTime = target._creationTime;
|
||||||
|
|
||||||
|
const before = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_channel", (q) =>
|
||||||
|
q.eq("channelId", args.channelId).lt("_creationTime", targetTime)
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.take(26);
|
||||||
|
|
||||||
|
const after = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_channel", (q) =>
|
||||||
|
q.eq("channelId", args.channelId).gt("_creationTime", targetTime)
|
||||||
|
)
|
||||||
|
.order("asc")
|
||||||
|
.take(26);
|
||||||
|
|
||||||
|
const hasOlder = before.length > 25;
|
||||||
|
const hasNewer = after.length > 25;
|
||||||
|
const olderMessages = before.slice(0, 25).reverse();
|
||||||
|
const newerMessages = after.slice(0, 25);
|
||||||
|
|
||||||
|
const allRaw = [...olderMessages, target, ...newerMessages];
|
||||||
|
const messages = await Promise.all(
|
||||||
|
allRaw.map((msg) => enrichMessage(ctx, msg, args.userId))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages, hasOlder, hasNewer, targetFound: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listBefore = query({
|
||||||
|
args: {
|
||||||
|
channelId: v.id("channels"),
|
||||||
|
beforeTimestamp: v.number(),
|
||||||
|
userId: v.optional(v.id("userProfiles")),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
returns: v.any(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const limit = args.limit ?? 50;
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_channel", (q) =>
|
||||||
|
q.eq("channelId", args.channelId).lt("_creationTime", args.beforeTimestamp)
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.take(limit + 1);
|
||||||
|
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = rows.slice(0, limit);
|
||||||
|
|
||||||
|
const messages = await Promise.all(
|
||||||
|
page.reverse().map((msg) => enrichMessage(ctx, msg, args.userId))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages, hasMore };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listAfter = query({
|
||||||
|
args: {
|
||||||
|
channelId: v.id("channels"),
|
||||||
|
afterTimestamp: v.number(),
|
||||||
|
userId: v.optional(v.id("userProfiles")),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
returns: v.any(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const limit = args.limit ?? 50;
|
||||||
|
const rows = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_channel", (q) =>
|
||||||
|
q.eq("channelId", args.channelId).gt("_creationTime", args.afterTimestamp)
|
||||||
|
)
|
||||||
|
.order("asc")
|
||||||
|
.take(limit + 1);
|
||||||
|
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = rows.slice(0, limit);
|
||||||
|
|
||||||
|
const messages = await Promise.all(
|
||||||
|
page.map((msg) => enrichMessage(ctx, msg, args.userId))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages, hasMore };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const remove = mutation({
|
export const remove = mutation({
|
||||||
args: { id: v.id("messages"), userId: v.id("userProfiles") },
|
args: { id: v.id("messages"), userId: v.id("userProfiles") },
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export default defineSchema({
|
|||||||
isScreenSharing: v.boolean(),
|
isScreenSharing: v.boolean(),
|
||||||
isServerMuted: v.boolean(),
|
isServerMuted: v.boolean(),
|
||||||
watchingStream: v.optional(v.id("userProfiles")),
|
watchingStream: v.optional(v.id("userProfiles")),
|
||||||
|
lastHeartbeat: v.optional(v.number()),
|
||||||
})
|
})
|
||||||
.index("by_channel", ["channelId"])
|
.index("by_channel", ["channelId"])
|
||||||
.index("by_user", ["userId"]),
|
.index("by_user", ["userId"]),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const getToken = action({
|
|||||||
const at = new AccessToken(apiKey, apiSecret, {
|
const at = new AccessToken(apiKey, apiSecret, {
|
||||||
identity: args.userId,
|
identity: args.userId,
|
||||||
name: args.username,
|
name: args.username,
|
||||||
|
ttl: "24h",
|
||||||
});
|
});
|
||||||
|
|
||||||
at.addGrant({
|
at.addGrant({
|
||||||
@@ -26,6 +27,7 @@ export const getToken = action({
|
|||||||
room: args.channelId,
|
room: args.channelId,
|
||||||
canPublish: true,
|
canPublish: true,
|
||||||
canSubscribe: true,
|
canSubscribe: true,
|
||||||
|
canPublishData: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = await at.toJwt();
|
const token = await at.toJwt();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation, internalMutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
import { getPublicStorageUrl } from "./storageUrl";
|
import { getPublicStorageUrl } from "./storageUrl";
|
||||||
import { getRolesForUser } from "./roles";
|
import { getRolesForUser } from "./roles";
|
||||||
|
|
||||||
@@ -33,8 +34,12 @@ export const join = mutation({
|
|||||||
isDeafened: args.isDeafened,
|
isDeafened: args.isDeafened,
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
isServerMuted: false,
|
isServerMuted: false,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Schedule stale cleanup to run in 90 seconds
|
||||||
|
await ctx.scheduler.runAfter(90000, internal.voiceState.cleanStaleStates, {});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -217,6 +222,7 @@ export const afkMove = mutation({
|
|||||||
isDeafened: currentState.isDeafened,
|
isDeafened: currentState.isDeafened,
|
||||||
isScreenSharing: false,
|
isScreenSharing: false,
|
||||||
isServerMuted: currentState.isServerMuted,
|
isServerMuted: currentState.isServerMuted,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear viewers watching the moved user's stream (screen sharing stops on AFK move)
|
// Clear viewers watching the moved user's stream (screen sharing stops on AFK move)
|
||||||
@@ -260,6 +266,56 @@ export const disconnectUser = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const heartbeat = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("userProfiles"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("voiceStates")
|
||||||
|
.withIndex("by_user", (q: any) => q.eq("userId", args.userId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, { lastHeartbeat: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cleanStaleStates = internalMutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const states = await ctx.db.query("voiceStates").collect();
|
||||||
|
const staleThreshold = Date.now() - 90_000; // 90 seconds
|
||||||
|
let hasActiveStates = false;
|
||||||
|
|
||||||
|
for (const s of states) {
|
||||||
|
if (s.lastHeartbeat && s.lastHeartbeat < staleThreshold) {
|
||||||
|
// Clear viewers watching this user's stream
|
||||||
|
for (const other of states) {
|
||||||
|
if (other.watchingStream === s.userId && other._id !== s._id) {
|
||||||
|
await ctx.db.patch(other._id, { watchingStream: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ctx.db.delete(s._id);
|
||||||
|
} else {
|
||||||
|
hasActiveStates = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-schedule if there are still active voice states
|
||||||
|
if (hasActiveStates) {
|
||||||
|
await ctx.scheduler.runAfter(90000, internal.voiceState.cleanStaleStates, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const moveUser = mutation({
|
export const moveUser = mutation({
|
||||||
args: {
|
args: {
|
||||||
actorUserId: v.id("userProfiles"),
|
actorUserId: v.id("userProfiles"),
|
||||||
@@ -303,6 +359,7 @@ export const moveUser = mutation({
|
|||||||
isDeafened: currentState.isDeafened,
|
isDeafened: currentState.isDeafened,
|
||||||
isScreenSharing: currentState.isScreenSharing,
|
isScreenSharing: currentState.isScreenSharing,
|
||||||
isServerMuted: currentState.isServerMuted,
|
isServerMuted: currentState.isServerMuted,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.jsx",
|
"main": "src/App.jsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
1
packages/shared/src/assets/icons/create.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="transparent" class=""></circle><path fill="currentColor" fill-rule="evenodd" d="M12 23a11 11 0 1 0 0-22 11 11 0 0 0 0 22Zm0-17a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z" clip-rule="evenodd" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 441 B |
1
packages/shared/src/assets/icons/create_category.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V8a3 3 0 0 0-3-3h-7l-1.4-2.1A2 2 0 0 0 8.92 2H5Zm7 7a1 1 0 0 1 1 1v3h3a1 1 0 0 1 0 2h-3v3a1 1 0 0 1-2 0v-3H8a1 1 0 1 1 0-2h3v-3a1 1 0 0 1 1-1Z" clip-rule="evenodd" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
1
packages/shared/src/assets/icons/create_event.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M19 14a1 1 0 0 1 1 1v3h3a1 1 0 0 1 0 2h-3v3a1 1 0 0 1-2 0v-3h-3a1 1 0 1 1 0-2h3v-3a1 1 0 0 1 1-1Z" fill="currentColor" class=""></path><path fill-rule="evenodd" d="M22 13.67c0 .12-.33.17-.39.06A2.87 2.87 0 0 0 19 12a3 3 0 0 0-3 3v.5a.5.5 0 0 1-.5.5H15a3 3 0 0 0-3 3c0 1.2.7 2.1 1.73 2.61.11.06.06.39-.06.39H5a3 3 0 0 1-3-3v-9a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v3.67ZM5.5 12a.5.5 0 0 0-.5.5v3c0 .28.22.5.5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3Z" clip-rule="evenodd" fill="currentColor" class=""></path><path d="M7 1a1 1 0 0 1 1 1v.75c0 .14.11.25.25.25h7.5c.14 0 .25-.11.25-.25V2a1 1 0 1 1 2 0v.75c0 .14.11.25.25.25H19a3 3 0 0 1 3 3 1 1 0 0 1-1 1H3a1 1 0 0 1-1-1 3 3 0 0 1 3-3h.75c.14 0 .25-.11.25-.25V2a1 1 0 0 1 1-1Z" fill="currentColor" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 916 B |
1
packages/shared/src/assets/icons/gem.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M11.47 6.55a.75.75 0 0 1 1.06 0l2.2 2.14c.14.14.23.34.23.55v5.52c0 .21-.09.4-.23.55l-2.2 2.14a.75.75 0 0 1-1.06 0l-2.2-2.14a.77.77 0 0 1-.23-.55V9.24c0-.21.09-.41.23-.55l2.2-2.14Z" class=""></path><path fill="currentColor" fill-rule="evenodd" d="M10.95 1.43a1.5 1.5 0 0 1 2.1 0l5.49 5.33c.3.29.46.68.46 1.1v8.44c-.04.35-.2.69-.46.94l-5.49 5.33-.11.1a1.5 1.5 0 0 1-2-.1l-5.48-5.33c-.3-.29-.46-.68-.46-1.1V7.86c0-.42.17-.81.46-1.1l5.49-5.33ZM7.29 7.76c-.2.2-.3.46-.3.73v7.02c0 .27.1.54.3.73l4 3.9a1 1 0 0 0 1.41 0l4-3.9c.2-.2.31-.46.31-.73V8.49c0-.27-.1-.54-.3-.73l-4-3.9a1 1 0 0 0-1.41 0l-4 3.9Z" clip-rule="evenodd" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 812 B |
1
packages/shared/src/assets/icons/notifications.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M9.7 2.89c.18-.07.32-.24.37-.43a2 2 0 0 1 3.86 0c.05.2.19.36.38.43A7 7 0 0 1 19 9.5v2.09c0 .12.05.24.13.33l1.1 1.22a3 3 0 0 1 .77 2.01v.28c0 .67-.34 1.29-.95 1.56-1.31.6-4 1.51-8.05 1.51-4.05 0-6.74-.91-8.05-1.5-.61-.28-.95-.9-.95-1.57v-.28a3 3 0 0 1 .77-2l1.1-1.23a.5.5 0 0 0 .13-.33V9.5a7 7 0 0 1 4.7-6.61ZM9.18 19.84A.16.16 0 0 0 9 20a3 3 0 1 0 6 0c0-.1-.09-.17-.18-.16a24.86 24.86 0 0 1-5.64 0Z" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 596 B |
1
packages/shared/src/assets/icons/notifications_off.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg x="0" y="0" class="icon__9293f" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M1.3 21.3a1 1 0 1 0 1.4 1.4l20-20a1 1 0 0 0-1.4-1.4l-20 20ZM3.13 16.13c.11.27.46.28.66.08L15.73 4.27a.47.47 0 0 0-.07-.74 6.97 6.97 0 0 0-1.35-.64.62.62 0 0 1-.38-.43 2 2 0 0 0-3.86 0 .62.62 0 0 1-.38.43A7 7 0 0 0 5 9.5v2.09a.5.5 0 0 1-.13.33l-1.1 1.22A3 3 0 0 0 3 15.15v.28c0 .24.04.48.13.7ZM18.64 9.36c.13-.13.36-.05.36.14v2.09c0 .12.05.24.13.33l1.1 1.22a3 3 0 0 1 .77 2.01v.28c0 .67-.34 1.29-.95 1.56-1.31.6-4 1.51-8.05 1.51-.46 0-.9-.01-1.33-.03a.48.48 0 0 1-.3-.83l8.27-8.28ZM9.18 19.84A.16.16 0 0 0 9 20a3 3 0 1 0 6 0c0-.1-.09-.17-.18-.16a24.84 24.84 0 0 1-5.64 0Z" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 780 B |
1
packages/shared/src/assets/icons/search.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="icon__0c4c4 visible__0c4c4" aria-label="Search" aria-hidden="false" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M15.62 17.03a9 9 0 1 1 1.41-1.41l4.68 4.67a1 1 0 0 1-1.42 1.42l-4.67-4.68ZM17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z" clip-rule="evenodd" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
1
packages/shared/src/assets/icons/shield.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg class="icon_c1e9c4" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M17.05 8.76a4.7 4.7 0 0 1-1.29-.88c-.36-.33-.7-.73-.88-1.13-.33-.73-.16-.77-1.08-.77h-3.64c-.91 0-.74.04-1.08.77a4.94 4.94 0 0 1-2.17 2.01c-.74.33-.85.14-.86.82 0 .88-.22 3.34 1.78 5.19a13.68 13.68 0 0 0 3.98 2.47c.09.03.08.04.17.04.09 0 .08 0 .17-.04.76-.25 2.67-1.25 3.98-2.47 2-1.85 1.78-4.3 1.78-5.19-.01-.68-.12-.49-.86-.82Z" class=""></path><path fill="currentColor" d="M19.91 5.41c-.84 0-1.52-.65-1.52-1.46v-.3c0-.9-.77-1.65-1.71-1.65H7.31c-.94 0-1.71.74-1.71 1.65v.3c0 .81-.68 1.46-1.52 1.46H3.7c-.94 0-1.7.73-1.7 1.64v3.52l.01.49c.05 3.11.94 4.69 2.92 6.63C6.72 19.46 11.58 22 11.99 22c.41 0 5.27-2.54 7.06-4.31 1.98-1.95 2.92-3.53 2.92-6.63L22 7.05c0-.9-.76-1.64-1.7-1.64h-.39Zm-2.18 10.74a21.76 21.76 0 0 1-5.58 3.38c-.1.03-.2.03-.31 0a21.76 21.76 0 0 1-5.58-3.38c-1.15-1.08-2.12-2.37-2.12-5.29v-.33l.03-2.57c0-.31.26-.57.59-.57a2.88 2.88 0 0 0 2.89-2.8v-.03c0-.3.26-.55.59-.55h7.56c.32 0 .59.25.59.55v.03c0 1.53 1.3 2.77 2.89 2.8.32 0 .59.26.59.57l.03 2.57v.34c0 2.92-.98 4.2-2.14 5.28h-.03Z" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
packages/shared/src/assets/icons/threads.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg x="0" y="0" class="icon__9293f" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M12 2.81a1 1 0 0 1 0-1.41l.36-.36a1 1 0 0 1 1.41 0l9.2 9.2a1 1 0 0 1 0 1.4l-.7.7a1 1 0 0 1-1.3.13l-9.54-6.72a1 1 0 0 1-.08-1.58l1-1L12 2.8ZM12 21.2a1 1 0 0 1 0 1.41l-.35.35a1 1 0 0 1-1.41 0l-9.2-9.19a1 1 0 0 1 0-1.41l.7-.7a1 1 0 0 1 1.3-.12l9.54 6.72a1 1 0 0 1 .07 1.58l-1 1 .35.36ZM15.66 16.8a1 1 0 0 1-1.38.28l-8.49-5.66A1 1 0 1 1 6.9 9.76l8.49 5.65a1 1 0 0 1 .27 1.39ZM17.1 14.25a1 1 0 1 0 1.11-1.66L9.73 6.93a1 1 0 0 0-1.11 1.66l8.49 5.66Z" fill="currentColor" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 653 B |
@@ -536,6 +536,16 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
|
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
|
||||||
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
|
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null);
|
||||||
|
|
||||||
|
// Focused mode state (for jumping to old messages not in paginated view)
|
||||||
|
const [focusedMode, setFocusedMode] = useState(false);
|
||||||
|
const [focusedMessageId, setFocusedMessageId] = useState(null);
|
||||||
|
const [focusedMessages, setFocusedMessages] = useState([]);
|
||||||
|
const [focusedHasOlder, setFocusedHasOlder] = useState(false);
|
||||||
|
const [focusedHasNewer, setFocusedHasNewer] = useState(false);
|
||||||
|
const [focusedLoading, setFocusedLoading] = useState(false);
|
||||||
|
const focusedModeRef = useRef(false);
|
||||||
|
const focusedLoadingMoreRef = useRef(false);
|
||||||
|
|
||||||
const inputDivRef = useRef(null);
|
const inputDivRef = useRef(null);
|
||||||
const savedRangeRef = useRef(null);
|
const savedRangeRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
@@ -579,7 +589,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
|
|
||||||
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
||||||
api.messages.list,
|
api.messages.list,
|
||||||
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
|
channelId && !focusedMode ? { channelId, userId: currentUserId || undefined } : "skip",
|
||||||
{ initialNumItems: 50 }
|
{ initialNumItems: 50 }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -632,6 +642,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const TAG_LENGTH = 32;
|
const TAG_LENGTH = 32;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (focusedModeRef.current) return;
|
||||||
if (!rawMessages || rawMessages.length === 0) {
|
if (!rawMessages || rawMessages.length === 0) {
|
||||||
setDecryptedMessages([]);
|
setDecryptedMessages([]);
|
||||||
return;
|
return;
|
||||||
@@ -880,6 +891,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setReactionPickerMsgId(null);
|
setReactionPickerMsgId(null);
|
||||||
setSlashQuery(null);
|
setSlashQuery(null);
|
||||||
setEphemeralMessages([]);
|
setEphemeralMessages([]);
|
||||||
|
// Reset focused mode
|
||||||
|
setFocusedMode(false);
|
||||||
|
focusedModeRef.current = false;
|
||||||
|
setFocusedMessageId(null);
|
||||||
|
setFocusedMessages([]);
|
||||||
|
setFocusedHasOlder(false);
|
||||||
|
setFocusedHasNewer(false);
|
||||||
|
setFocusedLoading(false);
|
||||||
|
focusedLoadingMoreRef.current = false;
|
||||||
floodAbortRef.current = true;
|
floodAbortRef.current = true;
|
||||||
isLoadingMoreRef.current = false;
|
isLoadingMoreRef.current = false;
|
||||||
loadMoreSettlingRef.current = false;
|
loadMoreSettlingRef.current = false;
|
||||||
@@ -900,7 +920,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
jumpToMessageIdRef.current = jumpToMessageId || null;
|
jumpToMessageIdRef.current = jumpToMessageId || null;
|
||||||
}, [jumpToMessageId]);
|
}, [jumpToMessageId]);
|
||||||
|
|
||||||
// Jump to a specific message (from search results)
|
// Jump to a specific message (from search results or pinned panel)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
|
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
|
||||||
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
|
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
|
||||||
@@ -916,16 +936,231 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
onClearJumpToMessage?.();
|
onClearJumpToMessage?.();
|
||||||
|
} else if (idx === -1 && !focusedModeRef.current) {
|
||||||
|
// Message not loaded in paginated view — enter focused mode
|
||||||
|
scrollLog('[SCROLL:jumpToMessage] entering focused mode for', jumpToMessageId);
|
||||||
|
setFocusedMessageId(jumpToMessageId);
|
||||||
|
onClearJumpToMessage?.();
|
||||||
}
|
}
|
||||||
}, [jumpToMessageId, decryptedMessages, onClearJumpToMessage]);
|
}, [jumpToMessageId, decryptedMessages, onClearJumpToMessage]);
|
||||||
|
|
||||||
// Safety timeout: clear jumpToMessageId if message never found (too old / not loaded)
|
// Safety timeout: clear jumpToMessageId only in normal mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jumpToMessageId) return;
|
if (!jumpToMessageId || focusedModeRef.current) return;
|
||||||
const timer = setTimeout(() => onClearJumpToMessage?.(), 5000);
|
const timer = setTimeout(() => onClearJumpToMessage?.(), 5000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [jumpToMessageId, onClearJumpToMessage]);
|
}, [jumpToMessageId, onClearJumpToMessage]);
|
||||||
|
|
||||||
|
// Focused mode: fetch messages around target
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedMessageId || !channelId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setFocusedLoading(true);
|
||||||
|
setFocusedMode(true);
|
||||||
|
focusedModeRef.current = true;
|
||||||
|
setDecryptedMessages([]);
|
||||||
|
decryptionDoneRef.current = false;
|
||||||
|
isInitialLoadRef.current = true;
|
||||||
|
initialScrollScheduledRef.current = false;
|
||||||
|
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||||
|
prevMessageCountRef.current = 0;
|
||||||
|
prevFirstMsgIdRef.current = null;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await convex.query(api.messages.listAround, {
|
||||||
|
channelId,
|
||||||
|
messageId: focusedMessageId,
|
||||||
|
userId: currentUserId || undefined,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!result.targetFound) {
|
||||||
|
scrollLog('[SCROLL:focusedMode] target not found');
|
||||||
|
setFocusedLoading(false);
|
||||||
|
// Auto-exit focused mode after brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setFocusedMode(false);
|
||||||
|
focusedModeRef.current = false;
|
||||||
|
setFocusedMessageId(null);
|
||||||
|
setFocusedMessages([]);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFocusedMessages(result.messages);
|
||||||
|
setFocusedHasOlder(result.hasOlder);
|
||||||
|
setFocusedHasNewer(result.hasNewer);
|
||||||
|
setFocusedLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load messages around target:', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setFocusedLoading(false);
|
||||||
|
setFocusedMode(false);
|
||||||
|
focusedModeRef.current = false;
|
||||||
|
setFocusedMessageId(null);
|
||||||
|
setFocusedMessages([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [focusedMessageId, channelId]);
|
||||||
|
|
||||||
|
// Focused mode: decrypt focusedMessages (parallel to normal decrypt effect)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedMode || focusedMessages.length === 0) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const buildFromCache = () => {
|
||||||
|
return focusedMessages.map(msg => {
|
||||||
|
const cached = messageDecryptionCache.get(msg.id);
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
content: cached?.content ?? '[Decrypting...]',
|
||||||
|
isVerified: cached?.isVerified ?? null,
|
||||||
|
decryptedReply: cached?.decryptedReply ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const newMessages = buildFromCache();
|
||||||
|
|
||||||
|
// Adjust firstItemIndex for prepended messages
|
||||||
|
const prevCount = prevMessageCountRef.current;
|
||||||
|
const newCount = newMessages.length;
|
||||||
|
if (newCount > prevCount && prevCount > 0) {
|
||||||
|
if (prevFirstMsgIdRef.current && newMessages[0]?.id !== prevFirstMsgIdRef.current) {
|
||||||
|
const prependedCount = newCount - prevCount;
|
||||||
|
setFirstItemIndex(prev => prev - prependedCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevMessageCountRef.current = newCount;
|
||||||
|
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
|
||||||
|
|
||||||
|
setDecryptedMessages(newMessages);
|
||||||
|
|
||||||
|
const processUncached = async () => {
|
||||||
|
if (!channelKey) return;
|
||||||
|
|
||||||
|
const needsDecryption = focusedMessages.filter(msg => {
|
||||||
|
const cached = messageDecryptionCache.get(msg.id);
|
||||||
|
if (!cached) return true;
|
||||||
|
if (msg.replyToNonce && msg.replyToContent && cached.decryptedReply === null) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsDecryption.length === 0) {
|
||||||
|
if (!cancelled) {
|
||||||
|
decryptionDoneRef.current = true;
|
||||||
|
setDecryptedMessages(buildFromCache());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptItems = [];
|
||||||
|
const decryptMsgMap = [];
|
||||||
|
const replyDecryptItems = [];
|
||||||
|
const replyMsgMap = [];
|
||||||
|
const verifyItems = [];
|
||||||
|
const verifyMsgMap = [];
|
||||||
|
|
||||||
|
for (const msg of needsDecryption) {
|
||||||
|
if (msg.ciphertext && msg.ciphertext.length >= TAG_LENGTH) {
|
||||||
|
const tag = msg.ciphertext.slice(-TAG_LENGTH);
|
||||||
|
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
|
||||||
|
decryptItems.push({ ciphertext: content, key: channelKey, iv: msg.nonce, tag });
|
||||||
|
decryptMsgMap.push(msg);
|
||||||
|
}
|
||||||
|
if (msg.replyToContent && msg.replyToNonce) {
|
||||||
|
const rTag = msg.replyToContent.slice(-TAG_LENGTH);
|
||||||
|
const rContent = msg.replyToContent.slice(0, -TAG_LENGTH);
|
||||||
|
replyDecryptItems.push({ ciphertext: rContent, key: channelKey, iv: msg.replyToNonce, tag: rTag });
|
||||||
|
replyMsgMap.push(msg);
|
||||||
|
}
|
||||||
|
if (msg.signature && msg.public_signing_key) {
|
||||||
|
verifyItems.push({ publicKey: msg.public_signing_key, message: msg.ciphertext, signature: msg.signature });
|
||||||
|
verifyMsgMap.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [decryptResults, replyResults, verifyResults] = await Promise.all([
|
||||||
|
decryptItems.length > 0 ? crypto.decryptBatch(decryptItems) : [],
|
||||||
|
replyDecryptItems.length > 0 ? crypto.decryptBatch(replyDecryptItems) : [],
|
||||||
|
verifyItems.length > 0 ? crypto.verifyBatch(verifyItems) : [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const decryptedMap = new Map();
|
||||||
|
for (let i = 0; i < decryptResults.length; i++) {
|
||||||
|
const msg = decryptMsgMap[i];
|
||||||
|
const result = decryptResults[i];
|
||||||
|
decryptedMap.set(msg.id, result.success ? result.data : '[Decryption Error]');
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyMap = new Map();
|
||||||
|
for (let i = 0; i < replyResults.length; i++) {
|
||||||
|
const msg = replyMsgMap[i];
|
||||||
|
const result = replyResults[i];
|
||||||
|
if (result.success) {
|
||||||
|
let text = result.data;
|
||||||
|
if (text.startsWith('{')) text = '[Attachment]';
|
||||||
|
else if (text.length > 100) text = text.substring(0, 100) + '...';
|
||||||
|
replyMap.set(msg.id, text);
|
||||||
|
} else {
|
||||||
|
replyMap.set(msg.id, '[Encrypted]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyMap = new Map();
|
||||||
|
for (let i = 0; i < verifyResults.length; i++) {
|
||||||
|
const msg = verifyMsgMap[i];
|
||||||
|
const verified = verifyResults[i].verified;
|
||||||
|
verifyMap.set(msg.id, verified === null ? null : (verifyResults[i].success && verified));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of needsDecryption) {
|
||||||
|
const content = decryptedMap.get(msg.id) ??
|
||||||
|
(msg.ciphertext && msg.ciphertext.length < TAG_LENGTH ? '[Invalid Encrypted Message]' : '[Encrypted Message - Key Missing]');
|
||||||
|
const isVerified = verifyMap.has(msg.id) ? verifyMap.get(msg.id) : null;
|
||||||
|
const decryptedReply = replyMap.get(msg.id) ?? null;
|
||||||
|
messageDecryptionCache.set(msg.id, { content, isVerified, decryptedReply });
|
||||||
|
}
|
||||||
|
|
||||||
|
evictCacheIfNeeded();
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
decryptionDoneRef.current = true;
|
||||||
|
setDecryptedMessages(buildFromCache());
|
||||||
|
};
|
||||||
|
|
||||||
|
processUncached();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [focusedMode, focusedMessages, channelKey]);
|
||||||
|
|
||||||
|
// Focused mode: scroll to target message after decryption completes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedMode || !focusedMessageId || !decryptionDoneRef.current || !decryptedMessages.length) return;
|
||||||
|
const idx = decryptedMessages.findIndex(m => m.id === focusedMessageId);
|
||||||
|
if (idx !== -1 && virtuosoRef.current) {
|
||||||
|
scrollLog('[SCROLL:focusedMode] scrolling to target', { focusedMessageId, idx });
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
virtuosoRef.current?.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById(`msg-${focusedMessageId}`);
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('message-highlight');
|
||||||
|
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [focusedMode, focusedMessageId, decryptedMessages]);
|
||||||
|
|
||||||
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
|
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
|
||||||
|
|
||||||
const isMentionedInContent = useCallback((content) => {
|
const isMentionedInContent = useCallback((content) => {
|
||||||
@@ -1080,6 +1315,33 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
|
|
||||||
// Virtuoso: startReached replaces IntersectionObserver
|
// Virtuoso: startReached replaces IntersectionObserver
|
||||||
const handleStartReached = useCallback(() => {
|
const handleStartReached = useCallback(() => {
|
||||||
|
if (focusedModeRef.current) {
|
||||||
|
if (focusedLoadingMoreRef.current || !focusedHasOlder) return;
|
||||||
|
const msgs = decryptedMessages;
|
||||||
|
if (!msgs.length) return;
|
||||||
|
const oldestTimestamp = new Date(msgs[0].created_at).getTime();
|
||||||
|
focusedLoadingMoreRef.current = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await convex.query(api.messages.listBefore, {
|
||||||
|
channelId,
|
||||||
|
beforeTimestamp: oldestTimestamp,
|
||||||
|
userId: currentUserId || undefined,
|
||||||
|
});
|
||||||
|
setFocusedMessages(prev => {
|
||||||
|
const existingIds = new Set(prev.map(m => m.id));
|
||||||
|
const newMsgs = result.messages.filter(m => !existingIds.has(m.id));
|
||||||
|
return [...newMsgs, ...prev];
|
||||||
|
});
|
||||||
|
setFocusedHasOlder(result.hasMore);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load older messages:', err);
|
||||||
|
} finally {
|
||||||
|
focusedLoadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isLoadingMoreRef.current) return;
|
if (isLoadingMoreRef.current) return;
|
||||||
if (statusRef.current === 'CanLoadMore') {
|
if (statusRef.current === 'CanLoadMore') {
|
||||||
isLoadingMoreRef.current = true;
|
isLoadingMoreRef.current = true;
|
||||||
@@ -1090,11 +1352,39 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
}
|
}
|
||||||
loadMoreRef.current(50);
|
loadMoreRef.current(50);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [focusedHasOlder, decryptedMessages, channelId, currentUserId]);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (!focusedModeRef.current || !focusedHasNewer || focusedLoadingMoreRef.current) return;
|
||||||
|
const msgs = decryptedMessages;
|
||||||
|
if (!msgs.length) return;
|
||||||
|
const newestTimestamp = new Date(msgs[msgs.length - 1].created_at).getTime();
|
||||||
|
focusedLoadingMoreRef.current = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await convex.query(api.messages.listAfter, {
|
||||||
|
channelId,
|
||||||
|
afterTimestamp: newestTimestamp,
|
||||||
|
userId: currentUserId || undefined,
|
||||||
|
});
|
||||||
|
setFocusedMessages(prev => {
|
||||||
|
const existingIds = new Set(prev.map(m => m.id));
|
||||||
|
const newMsgs = result.messages.filter(m => !existingIds.has(m.id));
|
||||||
|
return [...prev, ...newMsgs];
|
||||||
|
});
|
||||||
|
setFocusedHasNewer(result.hasMore);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load newer messages:', err);
|
||||||
|
} finally {
|
||||||
|
focusedLoadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [focusedHasNewer, decryptedMessages, channelId, currentUserId]);
|
||||||
|
|
||||||
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
|
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
|
||||||
const followOutput = useCallback((isAtBottom) => {
|
const followOutput = useCallback((isAtBottom) => {
|
||||||
|
if (focusedModeRef.current) return false;
|
||||||
|
|
||||||
const metrics = {
|
const metrics = {
|
||||||
isAtBottom,
|
isAtBottom,
|
||||||
userScrolledUp: userIsScrolledUpRef.current,
|
userScrolledUp: userIsScrolledUpRef.current,
|
||||||
@@ -1143,6 +1433,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
|
|
||||||
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
|
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
|
||||||
const handleAtBottomStateChange = useCallback((atBottom) => {
|
const handleAtBottomStateChange = useCallback((atBottom) => {
|
||||||
|
if (focusedModeRef.current) return;
|
||||||
scrollLog('[SCROLL:atBottomStateChange]', { atBottom, settling: loadMoreSettlingRef.current, userScrolledUp: userIsScrolledUpRef.current });
|
scrollLog('[SCROLL:atBottomStateChange]', { atBottom, settling: loadMoreSettlingRef.current, userScrolledUp: userIsScrolledUpRef.current });
|
||||||
if (loadMoreSettlingRef.current && atBottom) {
|
if (loadMoreSettlingRef.current && atBottom) {
|
||||||
return;
|
return;
|
||||||
@@ -1593,6 +1884,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
}
|
}
|
||||||
if (!messageContent && pendingFiles.length === 0) return;
|
if (!messageContent && pendingFiles.length === 0) return;
|
||||||
|
|
||||||
|
// Exit focused mode when sending a message
|
||||||
|
if (focusedModeRef.current) {
|
||||||
|
setFocusedMode(false);
|
||||||
|
focusedModeRef.current = false;
|
||||||
|
setFocusedMessageId(null);
|
||||||
|
setFocusedMessages([]);
|
||||||
|
setFocusedHasOlder(false);
|
||||||
|
setFocusedHasNewer(false);
|
||||||
|
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||||
|
prevMessageCountRef.current = 0;
|
||||||
|
prevFirstMsgIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Intercept slash commands
|
// Intercept slash commands
|
||||||
if (messageContent.startsWith('/') && pendingFiles.length === 0) {
|
if (messageContent.startsWith('/') && pendingFiles.length === 0) {
|
||||||
const parts = messageContent.slice(1).split(/\s+/);
|
const parts = messageContent.slice(1).split(/\s+/);
|
||||||
@@ -1753,6 +2057,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
} else {
|
||||||
|
// Message not in current view — enter focused mode
|
||||||
|
setFocusedMessageId(messageId);
|
||||||
}
|
}
|
||||||
}, [decryptedMessages]);
|
}, [decryptedMessages]);
|
||||||
|
|
||||||
@@ -1761,6 +2068,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } });
|
setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleJumpToPresent = useCallback(() => {
|
||||||
|
setFocusedMode(false);
|
||||||
|
focusedModeRef.current = false;
|
||||||
|
setFocusedMessageId(null);
|
||||||
|
setFocusedMessages([]);
|
||||||
|
setFocusedHasOlder(false);
|
||||||
|
setFocusedHasNewer(false);
|
||||||
|
setFocusedLoading(false);
|
||||||
|
focusedLoadingMoreRef.current = false;
|
||||||
|
setDecryptedMessages([]);
|
||||||
|
decryptionDoneRef.current = false;
|
||||||
|
isInitialLoadRef.current = true;
|
||||||
|
initialScrollScheduledRef.current = false;
|
||||||
|
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||||
|
prevMessageCountRef.current = 0;
|
||||||
|
prevFirstMsgIdRef.current = null;
|
||||||
|
userIsScrolledUpRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isDM = channelType === 'dm';
|
const isDM = channelType === 'dm';
|
||||||
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
|
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
|
||||||
|
|
||||||
@@ -1790,12 +2116,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
const renderListHeader = useCallback(() => {
|
const renderListHeader = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{status === 'LoadingMore' && (
|
{(status === 'LoadingMore' || (focusedMode && focusedHasOlder)) && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
||||||
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
|
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
|
{!focusedMode && status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
|
||||||
<div className="channel-beginning">
|
<div className="channel-beginning">
|
||||||
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
|
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
|
||||||
<h1 className="channel-beginning-title">
|
<h1 className="channel-beginning-title">
|
||||||
@@ -1811,7 +2137,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]);
|
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName, focusedMode, focusedHasOlder]);
|
||||||
|
|
||||||
// Stable Virtuoso components — avoids remounting Header/Footer every render
|
// Stable Virtuoso components — avoids remounting Header/Footer every render
|
||||||
const virtuosoComponents = useMemo(() => ({
|
const virtuosoComponents = useMemo(() => ({
|
||||||
@@ -1953,7 +2279,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="messages-list">
|
<div className="messages-list">
|
||||||
{status === 'LoadingFirstPage' ? (
|
{((!focusedMode && status === 'LoadingFirstPage') || focusedLoading) ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
|
||||||
<div className="loading-spinner" />
|
<div className="loading-spinner" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1962,13 +2288,14 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
scrollerRef={(el) => { scrollerElRef.current = el; }}
|
scrollerRef={(el) => { scrollerElRef.current = el; }}
|
||||||
firstItemIndex={firstItemIndex}
|
firstItemIndex={firstItemIndex}
|
||||||
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
{...(!focusedMode ? { initialTopMostItemIndex: { index: 'LAST', align: 'end' } } : {})}
|
||||||
alignToBottom={true}
|
alignToBottom={!focusedMode}
|
||||||
atBottomThreshold={20}
|
atBottomThreshold={20}
|
||||||
data={allDisplayMessages}
|
data={allDisplayMessages}
|
||||||
startReached={handleStartReached}
|
startReached={handleStartReached}
|
||||||
followOutput={followOutput}
|
endReached={focusedMode ? handleEndReached : undefined}
|
||||||
atBottomStateChange={handleAtBottomStateChange}
|
followOutput={focusedMode ? false : followOutput}
|
||||||
|
atBottomStateChange={focusedMode ? undefined : handleAtBottomStateChange}
|
||||||
increaseViewportBy={{ top: 400, bottom: 400 }}
|
increaseViewportBy={{ top: 400, bottom: 400 }}
|
||||||
defaultItemHeight={60}
|
defaultItemHeight={60}
|
||||||
computeItemKey={(index, item) => item.id || `idx-${index}`}
|
computeItemKey={(index, item) => item.id || `idx-${index}`}
|
||||||
@@ -1976,6 +2303,14 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
|
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{focusedMode && !focusedLoading && (
|
||||||
|
<button className="jump-to-present-btn" onClick={handleJumpToPresent}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ marginRight: '6px' }}>
|
||||||
|
<path d="M8 12l-4.5-4.5 1.06-1.06L8 9.88l3.44-3.44 1.06 1.06z"/>
|
||||||
|
</svg>
|
||||||
|
Jump to Present
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} isAttachment={contextMenu.isAttachment} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
|
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} isAttachment={contextMenu.isAttachment} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
|
||||||
{reactionPickerMsgId && (
|
{reactionPickerMsgId && (
|
||||||
|
|||||||
@@ -12,12 +12,21 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => {
|
|||||||
loadSources();
|
loadSources();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [isWebFallback, setIsWebFallback] = useState(false);
|
||||||
|
|
||||||
const loadSources = async () => {
|
const loadSources = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get screen/window sources from Electron
|
// Get screen/window sources from Electron
|
||||||
const desktopSources = await screenCapture.getScreenSources();
|
const desktopSources = await screenCapture.getScreenSources();
|
||||||
|
|
||||||
|
// If no desktop sources (web platform), use getDisplayMedia fallback
|
||||||
|
if (!desktopSources || desktopSources.length === 0) {
|
||||||
|
setIsWebFallback(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get video input devices (webcams)
|
// Get video input devices (webcams)
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
const videoDevices = devices.filter(d => d.kind === 'videoinput');
|
const videoDevices = devices.filter(d => d.kind === 'videoinput');
|
||||||
@@ -40,11 +49,35 @@ const ScreenShareModal = ({ onClose, onSelectSource }) => {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load sources:", err);
|
console.error("Failed to load sources:", err);
|
||||||
|
setIsWebFallback(true);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleWebFallback = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { frameRate: { ideal: 60, max: 60 } },
|
||||||
|
audio: shareAudio,
|
||||||
|
});
|
||||||
|
onSelectSource({ type: 'web_stream', stream, shareAudio });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'NotAllowedError') {
|
||||||
|
console.error('getDisplayMedia failed:', err);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-trigger the browser picker on web
|
||||||
|
useEffect(() => {
|
||||||
|
if (isWebFallback) {
|
||||||
|
handleWebFallback();
|
||||||
|
}
|
||||||
|
}, [isWebFallback]);
|
||||||
|
|
||||||
const handleSelect = (source) => {
|
const handleSelect = (source) => {
|
||||||
// If device, pass constraints differently (webcams don't have loopback audio)
|
// If device, pass constraints differently (webcams don't have loopback audio)
|
||||||
if (source.isDevice) {
|
if (source.isDevice) {
|
||||||
|
|||||||
@@ -971,6 +971,29 @@ const VoiceVideoTab = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Noise Suppression */}
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label style={labelStyle}>Voice Processing</label>
|
||||||
|
<label style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '10px',
|
||||||
|
cursor: 'pointer', color: 'var(--text-normal)', fontSize: '14px',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={localStorage.getItem('voiceNoiseSuppression') !== 'false'}
|
||||||
|
onChange={(e) => {
|
||||||
|
localStorage.setItem('voiceNoiseSuppression', String(e.target.checked));
|
||||||
|
}}
|
||||||
|
style={{ accentColor: 'var(--brand-experiment)', width: '18px', height: '18px', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
Noise Suppression
|
||||||
|
<span style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
|
||||||
|
— Reduces background noise like keyboard clicks and fans. Takes effect on next voice connect.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
LiveKitRoom,
|
|
||||||
VideoConference,
|
|
||||||
RoomAudioRenderer,
|
|
||||||
} from '@livekit/components-react';
|
|
||||||
import { useConvex } from 'convex/react';
|
|
||||||
import { api } from '../../../../convex/_generated/api';
|
|
||||||
import '@livekit/components-styles';
|
|
||||||
import { Track } from 'livekit-client';
|
|
||||||
|
|
||||||
const VoiceRoom = ({ channelId, channelName, userId, onDisconnect }) => {
|
|
||||||
const [token, setToken] = useState('');
|
|
||||||
const convex = useConvex();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchToken = async () => {
|
|
||||||
try {
|
|
||||||
const { token: lkToken } = await convex.action(api.voice.getToken, {
|
|
||||||
channelId,
|
|
||||||
userId,
|
|
||||||
username: localStorage.getItem('username') || 'Unknown'
|
|
||||||
});
|
|
||||||
if (lkToken) {
|
|
||||||
setToken(lkToken);
|
|
||||||
} else {
|
|
||||||
console.error('Failed to get token');
|
|
||||||
onDisconnect();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
onDisconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (channelId && userId) {
|
|
||||||
fetchToken();
|
|
||||||
}
|
|
||||||
}, [channelId, userId]);
|
|
||||||
|
|
||||||
if (!token) return <div style={{ color: 'white', padding: 20 }}>Connecting to Voice...</div>;
|
|
||||||
|
|
||||||
const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
|
|
||||||
<div style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
background: '#1a1b1e',
|
|
||||||
color: 'white',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderBottom: '1px solid #2f3136'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span>🔊</span>
|
|
||||||
<span style={{ fontWeight: 'bold' }}>{channelName}</span>
|
|
||||||
<span style={{ fontSize: 12, color: '#43b581', marginLeft: 8 }}>Connected</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onDisconnect}
|
|
||||||
style={{
|
|
||||||
background: '#ed4245',
|
|
||||||
border: 'none',
|
|
||||||
color: 'white',
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, position: 'relative' }}>
|
|
||||||
<LiveKitRoom
|
|
||||||
video={false}
|
|
||||||
audio={true}
|
|
||||||
token={token}
|
|
||||||
serverUrl={liveKitUrl}
|
|
||||||
data-lk-theme="default"
|
|
||||||
style={{ height: '100%' }}
|
|
||||||
onDisconnected={onDisconnect}
|
|
||||||
>
|
|
||||||
<VideoConference />
|
|
||||||
<RoomAudioRenderer />
|
|
||||||
</LiveKitRoom>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VoiceRoom;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Track, RoomEvent } from 'livekit-client';
|
import { Track, RoomEvent, ConnectionQuality } from 'livekit-client';
|
||||||
import { useVoice } from '../contexts/VoiceContext';
|
import { useVoice } from '../contexts/VoiceContext';
|
||||||
import ScreenShareModal from './ScreenShareModal';
|
import ScreenShareModal from './ScreenShareModal';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
@@ -44,11 +44,49 @@ const WATCH_STREAM_BUTTON_STYLE = {
|
|||||||
const THUMBNAIL_SIZE = { width: 120, height: 68 };
|
const THUMBNAIL_SIZE = { width: 120, height: 68 };
|
||||||
const BOTTOM_BAR_HEIGHT = 140;
|
const BOTTOM_BAR_HEIGHT = 140;
|
||||||
|
|
||||||
|
const ConnectionQualityIcon = ({ quality }) => {
|
||||||
|
const getColor = () => {
|
||||||
|
switch (quality) {
|
||||||
|
case ConnectionQuality.Excellent: return '#3ba55d';
|
||||||
|
case ConnectionQuality.Good: return '#3ba55d';
|
||||||
|
case ConnectionQuality.Poor: return '#faa61a';
|
||||||
|
case ConnectionQuality.Lost: return '#ed4245';
|
||||||
|
default: return '#72767d';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getBars = () => {
|
||||||
|
switch (quality) {
|
||||||
|
case ConnectionQuality.Excellent: return 4;
|
||||||
|
case ConnectionQuality.Good: return 3;
|
||||||
|
case ConnectionQuality.Poor: return 2;
|
||||||
|
case ConnectionQuality.Lost: return 1;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const color = getColor();
|
||||||
|
const bars = getBars();
|
||||||
|
return (
|
||||||
|
<svg width="16" height="14" viewBox="0 0 16 14" style={{ flexShrink: 0 }}>
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={i * 4}
|
||||||
|
y={10 - i * 3}
|
||||||
|
width="3"
|
||||||
|
height={4 + i * 3}
|
||||||
|
rx="0.5"
|
||||||
|
fill={i < bars ? color : 'rgba(255,255,255,0.2)'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// --- Components ---
|
// --- Components ---
|
||||||
|
|
||||||
const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
||||||
const cameraTrack = useParticipantTrack(participant, 'camera');
|
const cameraTrack = useParticipantTrack(participant, 'camera');
|
||||||
const { isPersonallyMuted, voiceStates } = useVoice();
|
const { isPersonallyMuted, voiceStates, connectionQualities } = useVoice();
|
||||||
const isMicEnabled = participant.isMicrophoneEnabled;
|
const isMicEnabled = participant.isMicrophoneEnabled;
|
||||||
const isPersonalMuted = isPersonallyMuted(participant.identity);
|
const isPersonalMuted = isPersonallyMuted(participant.identity);
|
||||||
const displayName = username || participant.identity;
|
const displayName = username || participant.identity;
|
||||||
@@ -111,6 +149,7 @@ const ParticipantTile = ({ participant, username, avatarUrl }) => {
|
|||||||
<ColoredIcon src={personalMuteIcon} color="white" size="16px" />
|
<ColoredIcon src={personalMuteIcon} color="white" size="16px" />
|
||||||
) : isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
|
) : isMicEnabled ? '\u{1F3A4}' : '\u{1F507}'}
|
||||||
{displayName}
|
{displayName}
|
||||||
|
<ConnectionQualityIcon quality={connectionQualities[participant.identity]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -581,7 +620,7 @@ const FocusedStreamView = ({
|
|||||||
|
|
||||||
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
||||||
const [participants, setParticipants] = useState([]);
|
const [participants, setParticipants] = useState([]);
|
||||||
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio } = useVoice();
|
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio, isReconnecting } = useVoice();
|
||||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||||
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
|
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
|
||||||
const screenShareAudioTrackRef = useRef(null);
|
const screenShareAudioTrackRef = useRef(null);
|
||||||
@@ -670,7 +709,10 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
}
|
}
|
||||||
let stream;
|
let stream;
|
||||||
if (selection.type === 'device') {
|
if (selection.type === 'web_stream') {
|
||||||
|
// Web fallback: stream already obtained via getDisplayMedia
|
||||||
|
stream = selection.stream;
|
||||||
|
} else if (selection.type === 'device') {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: { deviceId: { exact: selection.deviceId } },
|
video: { deviceId: { exact: selection.deviceId } },
|
||||||
audio: false
|
audio: false
|
||||||
@@ -694,7 +736,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
minHeight: 720,
|
minHeight: 720,
|
||||||
maxHeight: 1080,
|
maxHeight: 1080,
|
||||||
maxFrameRate: 30
|
maxFrameRate: 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -712,7 +754,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
minHeight: 720,
|
minHeight: 720,
|
||||||
maxHeight: 1080,
|
maxHeight: 1080,
|
||||||
maxFrameRate: 30
|
maxFrameRate: 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -934,6 +976,29 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reconnection Banner */}
|
||||||
|
{isReconnecting && (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#faa61a',
|
||||||
|
color: '#000',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '600',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '14px', height: '14px', borderRadius: '50%',
|
||||||
|
border: '2px solid #000', borderTopColor: 'transparent',
|
||||||
|
animation: 'spin 0.8s linear infinite',
|
||||||
|
}} />
|
||||||
|
Reconnecting...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '80px',
|
height: '80px',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Room, RoomEvent } 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';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
@@ -66,6 +66,8 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const isMovingRef = useRef(false);
|
const isMovingRef = useRef(false);
|
||||||
const isDMCallRef = useRef(false);
|
const isDMCallRef = useRef(false);
|
||||||
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
|
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
|
||||||
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||||
|
const [connectionQualities, setConnectionQualities] = useState({});
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
// Apply volume to LiveKit participant (factoring in global output volume)
|
// Apply volume to LiveKit participant (factoring in global output volume)
|
||||||
const participant = room?.remoteParticipants?.get(userId);
|
const participant = room?.remoteParticipants?.get(userId);
|
||||||
const globalVol = globalOutputVolume / 100;
|
const globalVol = globalOutputVolume / 100;
|
||||||
if (participant) participant.setVolume(Math.min(1, (volume / 100) * globalVol));
|
if (participant) participant.setVolume((volume / 100) * globalVol);
|
||||||
// Sync personal mute state
|
// Sync personal mute state
|
||||||
if (volume === 0) {
|
if (volume === 0) {
|
||||||
setPersonallyMutedUsers(prev => {
|
setPersonallyMutedUsers(prev => {
|
||||||
@@ -153,7 +155,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const vol = userVolumes[userId] ?? 100;
|
const vol = userVolumes[userId] ?? 100;
|
||||||
const restoreVol = vol === 0 ? 100 : vol;
|
const restoreVol = vol === 0 ? 100 : vol;
|
||||||
const participant = room?.remoteParticipants?.get(userId);
|
const participant = room?.remoteParticipants?.get(userId);
|
||||||
if (participant) participant.setVolume(Math.min(1, (restoreVol / 100) * globalVol));
|
if (participant) participant.setVolume((restoreVol / 100) * globalVol);
|
||||||
// Update stored volume if it was 0
|
// Update stored volume if it was 0
|
||||||
if (vol === 0) {
|
if (vol === 0) {
|
||||||
setUserVolumes(p => {
|
setUserVolumes(p => {
|
||||||
@@ -251,6 +253,8 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const storedInputDevice = localStorage.getItem('voiceInputDevice');
|
const storedInputDevice = localStorage.getItem('voiceInputDevice');
|
||||||
const storedOutputDevice = localStorage.getItem('voiceOutputDevice');
|
const storedOutputDevice = localStorage.getItem('voiceOutputDevice');
|
||||||
|
|
||||||
|
const noiseSuppression = localStorage.getItem('voiceNoiseSuppression') !== 'false'; // default true
|
||||||
|
|
||||||
const newRoom = new Room({
|
const newRoom = new Room({
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
@@ -258,20 +262,27 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
autoGainControl: true,
|
autoGainControl: true,
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: false,
|
noiseSuppression,
|
||||||
channelCount: 1,
|
channelCount: 1,
|
||||||
sampleRate: 48000,
|
sampleRate: 48000,
|
||||||
...(storedInputDevice && storedInputDevice !== 'default' ? { deviceId: { exact: storedInputDevice } } : {}),
|
...(storedInputDevice && storedInputDevice !== 'default' ? { deviceId: { exact: storedInputDevice } } : {}),
|
||||||
},
|
},
|
||||||
|
videoCaptureDefaults: {
|
||||||
|
resolution: VideoPresets.h720.resolution,
|
||||||
|
},
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
audioPreset: { maxBitrate: 96_000 },
|
audioPreset: { maxBitrate: 96_000 },
|
||||||
dtx: false,
|
dtx: false,
|
||||||
red: true,
|
red: true,
|
||||||
|
videoEncoding: VideoPresets.h720.encoding,
|
||||||
|
videoCodec: 'vp9',
|
||||||
screenShareEncoding: {
|
screenShareEncoding: {
|
||||||
maxBitrate: 10_000_000,
|
maxBitrate: 10_000_000,
|
||||||
maxFramerate: 60,
|
maxFramerate: 60,
|
||||||
},
|
},
|
||||||
screenShareSimulcastLayers: [],
|
screenShareSimulcastLayers: [
|
||||||
|
{ maxBitrate: 2_000_000, maxFramerate: 15, width: 1280, height: 720 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
|
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
|
||||||
@@ -286,7 +297,6 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
|
|
||||||
setRoom(newRoom);
|
setRoom(newRoom);
|
||||||
setConnectionState('connected');
|
setConnectionState('connected');
|
||||||
window.voiceRoom = newRoom;
|
|
||||||
// Play custom join sound if set, otherwise default
|
// Play custom join sound if set, otherwise default
|
||||||
if (myJoinSoundUrl) {
|
if (myJoinSoundUrl) {
|
||||||
playSoundUrl(myJoinSoundUrl);
|
playSoundUrl(myJoinSoundUrl);
|
||||||
@@ -316,14 +326,32 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
setRoom(null);
|
setRoom(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setActiveSpeakers(new Set());
|
setActiveSpeakers(new Set());
|
||||||
|
setConnectionQualities({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-reconnect on token expiry
|
||||||
|
if (reason === DisconnectReason.TOKEN_EXPIRED) {
|
||||||
|
console.log('Token expired, auto-reconnecting...');
|
||||||
|
setRoom(null);
|
||||||
|
setToken(null);
|
||||||
|
setActiveSpeakers(new Set());
|
||||||
|
setConnectionQualities({});
|
||||||
|
try {
|
||||||
|
await connectToVoice(channelId, channelName, userId, isDMCallRef.current);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Auto-reconnect failed:', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
playSound('leave');
|
playSound('leave');
|
||||||
setConnectionState('disconnected');
|
setConnectionState('disconnected');
|
||||||
setActiveChannelId(null);
|
setActiveChannelId(null);
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setActiveSpeakers(new Set());
|
setActiveSpeakers(new Set());
|
||||||
|
setConnectionQualities({});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await convex.mutation(api.voiceState.leave, { userId });
|
await convex.mutation(api.voiceState.leave, { userId });
|
||||||
@@ -336,6 +364,25 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
setActiveSpeakers(new Set(speakers.map(p => p.identity)));
|
setActiveSpeakers(new Set(speakers.map(p => p.identity)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
newRoom.on(RoomEvent.Reconnecting, () => {
|
||||||
|
console.warn('Voice room reconnecting...');
|
||||||
|
setIsReconnecting(true);
|
||||||
|
setConnectionState('reconnecting');
|
||||||
|
});
|
||||||
|
|
||||||
|
newRoom.on(RoomEvent.Reconnected, () => {
|
||||||
|
console.log('Voice room reconnected');
|
||||||
|
setIsReconnecting(false);
|
||||||
|
setConnectionState('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
newRoom.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
||||||
|
setConnectionQualities(prev => ({
|
||||||
|
...prev,
|
||||||
|
[participant.identity]: quality,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Voice Connection Failed:', err);
|
console.error('Voice Connection Failed:', err);
|
||||||
setConnectionState('error');
|
setConnectionState('error');
|
||||||
@@ -343,6 +390,24 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Heartbeat: send periodic heartbeat to prevent ghost voice states
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeChannelId) return;
|
||||||
|
const userId = localStorage.getItem('userId');
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const sendHeartbeat = () => {
|
||||||
|
convex.mutation(api.voiceState.heartbeat, { userId }).catch(e =>
|
||||||
|
console.warn('Heartbeat failed:', e)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send immediately, then every 30 seconds
|
||||||
|
sendHeartbeat();
|
||||||
|
const interval = setInterval(sendHeartbeat, 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [activeChannelId, convex]);
|
||||||
|
|
||||||
// Detect when another user moves us to a different voice channel
|
// Detect when another user moves us to a different voice channel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const myUserId = localStorage.getItem('userId');
|
const myUserId = localStorage.getItem('userId');
|
||||||
@@ -395,7 +460,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
participant.setVolume(0);
|
participant.setVolume(0);
|
||||||
} else {
|
} else {
|
||||||
const userVol = (userVolumes[identity] ?? 100) / 100;
|
const userVol = (userVolumes[identity] ?? 100) / 100;
|
||||||
participant.setVolume(Math.min(1, userVol * globalVol));
|
participant.setVolume(userVol * globalVol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -648,6 +713,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [room]);
|
}, [room]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceContext.Provider value={{
|
<VoiceContext.Provider value={{
|
||||||
activeChannelId,
|
activeChannelId,
|
||||||
@@ -682,6 +748,8 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
globalOutputVolume,
|
globalOutputVolume,
|
||||||
setGlobalOutputVolume,
|
setGlobalOutputVolume,
|
||||||
isReceivingScreenShareAudio,
|
isReceivingScreenShareAudio,
|
||||||
|
isReconnecting,
|
||||||
|
connectionQualities,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
{room && (
|
{room && (
|
||||||
|
|||||||
@@ -1938,6 +1938,34 @@ body {
|
|||||||
animation: messageHighlight 2s ease;
|
animation: messageHighlight 2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
JUMP TO PRESENT BUTTON
|
||||||
|
============================================ */
|
||||||
|
.jump-to-present-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background-color: var(--brand-experiment, #5865f2);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-present-btn:hover {
|
||||||
|
background-color: #4752c4;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
SYSTEM MESSAGES
|
SYSTEM MESSAGES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||