diff --git a/TODO.md b/TODO.md
index b2768b4..302de8e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -5,3 +5,40 @@
- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile.
- Add photo / video albums like Commit https://commet.chat/
+
+
+
+- When resiszing and if im already at the bottom of the channel, i want to make sure i stay at the bottom of the channel.
+
+- You should not be allowed to edit a image or video file upload message.
+
+
+- Is their anyway we can show the users master key when they are logged in. Letting them download it to store it somewhere safe. Since if they forget their password its the only way we can techically recover their account. So this is what MEGA says on how they recover their users passwords
+
+"As the user’s password is effectively the root of all client-side encryption in a user’s account, forgetting or
+losing it results in the inability to decrypt the user’s data, which is highly destructive. For this reason, MEGA
+allows and highly recommends users to export their “Recovery Key” (which is technically their Master Key).
+MEGA clients detect when a user has not entered their password for a lengthy period of time (for example
+due to enabling the “remember me” checkbox while logging in) and reminds users of the importance of their
+password. This reminder dialog prompts the user to test their password and/or export their Recovery Key.
+MEGA has a convenient recovery interface where novice users are guided based on their circumstances in case
+of password loss: https://mega.nz/recovery
+MEGA has found that users who forget or lose their password are often still logged in on another client (e.g. a
+mobile app or MEGAsync). For this reason, MEGA allows users with an active session to change their password
+in that client without first proving knowledge of the current password.
+If the user has no other accessible active sessions, the user can use the Recovery Key (which is in effect the
+Master Key) to reset the password of the account. Technically, the user would re-encrypt the Master Key with
+a new password. Such a procedure requires email confirmation, so access to the Recovery Key alone is not
+sufficient to breach a MEGA account"
+
+We dont do emails. So as long as you have the master key we will allow you to reset your password.
+
+
+
+
+
+- Lets make it so if i right click on a category i get a popup for that category for options like "Edit Category", "Delete Category".
+
+
+
+- Lets make it so if i right click on someone on the memebers list or if they are in voice we get a couple more options. As is if they are in voice we get server mute and all that. Thats fine only when they are in voice but we should have more options for someone like, Change Nickname (If you have permission to change people nicknames), Message (To send them a direct message), Start a Call (To start a private call). Also this change nickname is for the whole server to see. So everywhere their username would be will be their nickname instead of their username. So if they have a nickname it will show up in the chat and in the members list instead of their username for everyone to see.
\ No newline at end of file
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index 194485c..7a152b4 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -22,6 +22,7 @@ import type * as messages from "../messages.js";
import type * as presence from "../presence.js";
import type * as reactions from "../reactions.js";
import type * as readState from "../readState.js";
+import type * as recovery from "../recovery.js";
import type * as roles from "../roles.js";
import type * as serverSettings from "../serverSettings.js";
import type * as storageUrl from "../storageUrl.js";
@@ -50,6 +51,7 @@ declare const fullApi: ApiFromModules<{
presence: typeof presence;
reactions: typeof reactions;
readState: typeof readState;
+ recovery: typeof recovery;
roles: typeof roles;
serverSettings: typeof serverSettings;
storageUrl: typeof storageUrl;
diff --git a/convex/auth.ts b/convex/auth.ts
index 844de2e..9c8aa50 100644
--- a/convex/auth.ts
+++ b/convex/auth.ts
@@ -1,4 +1,4 @@
-import { query, mutation } from "./_generated/server";
+import { query, mutation, internalQuery, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl";
@@ -284,3 +284,74 @@ export const updateStatus = mutation({
return null;
},
});
+
+// Get encrypted private keys + public signing key for recovery
+export const getRecoveryData = query({
+ args: { username: v.string() },
+ returns: v.union(
+ v.object({
+ encryptedPrivateKeys: v.string(),
+ publicSigningKey: v.string(),
+ }),
+ v.object({ error: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ const user = await ctx.db
+ .query("userProfiles")
+ .withIndex("by_username", (q) => q.eq("username", args.username))
+ .unique();
+
+ if (!user) {
+ return { error: "User not found" };
+ }
+
+ return {
+ encryptedPrivateKeys: user.encryptedPrivateKeys,
+ publicSigningKey: user.publicSigningKey,
+ };
+ },
+});
+
+// Internal: get userId + publicSigningKey for recovery action verification
+export const getUserForRecovery = internalQuery({
+ args: { username: v.string() },
+ returns: v.union(
+ v.object({
+ userId: v.id("userProfiles"),
+ publicSigningKey: v.string(),
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ const user = await ctx.db
+ .query("userProfiles")
+ .withIndex("by_username", (q) => q.eq("username", args.username))
+ .unique();
+
+ if (!user) return null;
+
+ return {
+ userId: user._id,
+ publicSigningKey: user.publicSigningKey,
+ };
+ },
+});
+
+// Internal: update credentials after password reset
+export const updateCredentials = internalMutation({
+ args: {
+ userId: v.id("userProfiles"),
+ clientSalt: v.string(),
+ encryptedMasterKey: v.string(),
+ hashedAuthKey: v.string(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ await ctx.db.patch(args.userId, {
+ clientSalt: args.clientSalt,
+ encryptedMasterKey: args.encryptedMasterKey,
+ hashedAuthKey: args.hashedAuthKey,
+ });
+ return null;
+ },
+});
diff --git a/convex/recovery.ts b/convex/recovery.ts
new file mode 100644
index 0000000..180ecd0
--- /dev/null
+++ b/convex/recovery.ts
@@ -0,0 +1,70 @@
+"use node";
+
+import { action } from "./_generated/server";
+import { internal } from "./_generated/api";
+import { v } from "convex/values";
+import crypto from "crypto";
+
+export const resetPasswordAction = action({
+ args: {
+ username: v.string(),
+ newSalt: v.string(),
+ newEncryptedMK: v.string(),
+ newHAK: v.string(),
+ signature: v.string(),
+ timestamp: v.number(),
+ },
+ returns: v.union(
+ v.object({ success: v.boolean() }),
+ v.object({ error: v.string() })
+ ),
+ handler: async (ctx, args) => {
+ // Validate timestamp is within 5 minutes
+ const now = Date.now();
+ if (Math.abs(now - args.timestamp) > 5 * 60 * 1000) {
+ return { error: "Request expired. Please try again." };
+ }
+
+ // Get user data
+ const userData = await ctx.runQuery(internal.auth.getUserForRecovery, {
+ username: args.username,
+ });
+
+ if (!userData) {
+ return { error: "User not found" };
+ }
+
+ // Verify Ed25519 signature
+ const message = `password-reset:${args.username}:${args.timestamp}`;
+ try {
+ const publicKeyObj = crypto.createPublicKey({
+ key: userData.publicSigningKey,
+ format: "pem",
+ type: "spki",
+ });
+
+ const isValid = crypto.verify(
+ null,
+ Buffer.from(message),
+ publicKeyObj,
+ Buffer.from(args.signature, "hex")
+ );
+
+ if (!isValid) {
+ return { error: "Invalid recovery key" };
+ }
+ } catch (e) {
+ return { error: "Signature verification failed" };
+ }
+
+ // Update credentials
+ await ctx.runMutation(internal.auth.updateCredentials, {
+ userId: userData.userId,
+ clientSalt: args.newSalt,
+ encryptedMasterKey: args.newEncryptedMK,
+ hashedAuthKey: args.newHAK,
+ });
+
+ return { success: true };
+ },
+});
diff --git a/packages/shared/src/App.jsx b/packages/shared/src/App.jsx
index 6251fc7..bc47c91 100644
--- a/packages/shared/src/App.jsx
+++ b/packages/shared/src/App.jsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
+import Recovery from './pages/Recovery';
import Chat from './pages/Chat';
import { usePlatform } from './platform';
import { useSearch } from './contexts/SearchContext';
@@ -37,6 +38,7 @@ function AuthGuard({ children }) {
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
sessionStorage.setItem('signingKey', savedSession.signingKey);
sessionStorage.setItem('privateKey', savedSession.privateKey);
+ if (savedSession.masterKey) sessionStorage.setItem('masterKey', savedSession.masterKey);
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
searchCtx?.initialize();
// Restore user preferences from file-based backup into localStorage
@@ -70,7 +72,7 @@ function AuthGuard({ children }) {
useEffect(() => {
if (authState === 'loading') return;
- const isAuthPage = location.pathname === '/' || location.pathname === '/register';
+ const isAuthPage = location.pathname === '/' || location.pathname === '/register' || location.pathname === '/recovery';
const hasSession = sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey');
if (hasSession && isAuthPage) {
@@ -105,6 +107,7 @@ function App() {
} />
} />
+ } />
} />
diff --git a/packages/shared/src/assets/icons/chat.svg b/packages/shared/src/assets/icons/chat.svg
new file mode 100644
index 0000000..da3b182
--- /dev/null
+++ b/packages/shared/src/assets/icons/chat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/shared/src/assets/icons/eye.svg b/packages/shared/src/assets/icons/eye.svg
new file mode 100644
index 0000000..b250157
--- /dev/null
+++ b/packages/shared/src/assets/icons/eye.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/shared/src/assets/icons/fullscreen.svg b/packages/shared/src/assets/icons/fullscreen.svg
new file mode 100644
index 0000000..a2637a6
--- /dev/null
+++ b/packages/shared/src/assets/icons/fullscreen.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/shared/src/assets/icons/popout.svg b/packages/shared/src/assets/icons/popout.svg
new file mode 100644
index 0000000..43b65bc
--- /dev/null
+++ b/packages/shared/src/assets/icons/popout.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/shared/src/assets/icons/stop_sharing.svg b/packages/shared/src/assets/icons/stop_sharing.svg
new file mode 100644
index 0000000..18c86ad
--- /dev/null
+++ b/packages/shared/src/assets/icons/stop_sharing.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/shared/src/assets/icons/volume.svg b/packages/shared/src/assets/icons/volume.svg
new file mode 100644
index 0000000..24f789f
--- /dev/null
+++ b/packages/shared/src/assets/icons/volume.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/shared/src/components/ChatArea.jsx b/packages/shared/src/components/ChatArea.jsx
index fada6b7..1817fb8 100644
--- a/packages/shared/src/components/ChatArea.jsx
+++ b/packages/shared/src/components/ChatArea.jsx
@@ -538,6 +538,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const typingTimeoutRef = useRef(null);
const lastTypingEmitRef = useRef(0);
const isInitialLoadRef = useRef(true);
+ const initialScrollScheduledRef = useRef(false);
const decryptionDoneRef = useRef(false);
const channelLoadIdRef = useRef(0);
const jumpToMessageIdRef = useRef(null);
@@ -559,6 +560,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const prevMessageCountRef = useRef(0);
const prevFirstMsgIdRef = useRef(null);
const isAtBottomRef = useRef(true);
+ const isLoadingMoreRef = useRef(false);
const convex = useConvex();
@@ -576,6 +578,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
useEffect(() => {
statusRef.current = status;
loadMoreRef.current = loadMore;
+ if (status !== 'LoadingMore') {
+ isLoadingMoreRef.current = false;
+ }
}, [status, loadMore]);
const typingData = useQuery(
@@ -632,7 +637,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
});
};
- setDecryptedMessages(buildFromCache());
+ const newMessages = buildFromCache();
+
+ // Adjust firstItemIndex atomically with data to prevent Virtuoso scroll jump
+ 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);
// Phase 2: Batch-decrypt only uncached messages in background
const processUncached = async () => {
@@ -661,7 +680,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
decryptionDoneRef.current = true;
setDecryptedMessages(buildFromCache());
- if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
+ if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
+ initialScrollScheduledRef.current = true;
const loadId = channelLoadIdRef.current;
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
requestAnimationFrame(() => requestAnimationFrame(() => {
@@ -670,6 +690,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
+ setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
}
}
return;
@@ -786,7 +807,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// After decryption, items may be taller — re-scroll to bottom.
// Double-rAF waits for paint + ResizeObserver cycle; escalating timeouts are safety nets.
- if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
+ if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
+ initialScrollScheduledRef.current = true;
const loadId = channelLoadIdRef.current;
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
requestAnimationFrame(() => requestAnimationFrame(() => {
@@ -795,6 +817,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
+ setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
}
};
@@ -825,6 +848,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
channelLoadIdRef.current += 1;
setDecryptedMessages([]);
isInitialLoadRef.current = true;
+ initialScrollScheduledRef.current = false;
decryptionDoneRef.current = false;
pingSeededRef.current = false;
notifiedMessageIdsRef.current = new Set();
@@ -837,6 +861,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setSlashQuery(null);
setEphemeralMessages([]);
floodAbortRef.current = true;
+ isLoadingMoreRef.current = false;
setFirstItemIndex(INITIAL_FIRST_INDEX);
prevMessageCountRef.current = 0;
prevFirstMsgIdRef.current = null;
@@ -1016,24 +1041,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
// Virtuoso: startReached replaces IntersectionObserver
const handleStartReached = useCallback(() => {
+ if (isLoadingMoreRef.current) return;
if (statusRef.current === 'CanLoadMore') {
+ isLoadingMoreRef.current = true;
loadMoreRef.current(50);
}
}, []);
- // Virtuoso: firstItemIndex management for prepend without jitter
- useEffect(() => {
- const prevCount = prevMessageCountRef.current;
- const newCount = decryptedMessages.length;
- if (newCount > prevCount && prevCount > 0) {
- if (prevFirstMsgIdRef.current && decryptedMessages[0]?.id !== prevFirstMsgIdRef.current) {
- const prependedCount = newCount - prevCount;
- setFirstItemIndex(prev => prev - prependedCount);
- }
- }
- prevMessageCountRef.current = newCount;
- prevFirstMsgIdRef.current = decryptedMessages[0]?.id || null;
- }, [decryptedMessages]);
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
const followOutput = useCallback((isAtBottom) => {
@@ -1068,12 +1082,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
if (channelLoadIdRef.current === loadId) {
isInitialLoadRef.current = false;
}
- }, 1500);
+ }, 300);
}
- } else if (isInitialLoadRef.current && decryptionDoneRef.current) {
- // Content resize pushed us off bottom during initial load — snap back
- const el = scrollerElRef.current;
- if (el) el.scrollTop = el.scrollHeight;
}
}, [markChannelAsRead]);
@@ -1668,26 +1678,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return (
<>
{status === 'LoadingMore' && (
- <>
- {[
- { name: 80, lines: [260, 180] },
- { name: 60, lines: [310] },
- { name: 100, lines: [240, 140] },
- { name: 70, lines: [290] },
- { name: 90, lines: [200, 260] },
- { name: 55, lines: [330] },
- ].map((s, i) => (
-
+ Your Recovery Key allows you to reset your password without losing access to your encrypted messages.
+ Store it somewhere safe — if you forget your password, this is the only way to recover your account.
+
+
+ {!masterKey ? (
+
+ Recovery Key is not available in this session. Please log out and log back in to access it.
+
+ ) : !revealed ? (
+
+ {/* Warning box */}
+
+
+ Warning
+
+
+ Anyone with your Recovery Key can reset your password and take control of your account.
+ Only reveal it in a private, secure environment.
+