feat: Introduce multi-platform architecture for Electron and Web clients with shared UI components, Convex backend for messaging, and integrated search functionality.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-02-16 13:08:39 -06:00
parent 8ff9213b34
commit ec12313996
49 changed files with 2449 additions and 3914 deletions

48
TODO.md
View File

@@ -1,56 +1,14 @@
- I want to give users the choice to update the app. Can we make updating work 2 ways. One, optional updates, i want to somehow make some updates marked as optional, where techinically older versions will still work so we dont care if they are on a older version. And some updates non optional, where we will require users to update to the latest version to continue using the app. So for example when they launch the app we will check if their is a update but we dont update the app right away. We will show a download icon like discord in the header, that will be the update.svg icon. Make the icon use this color "hsl(138.353 calc(1*38.117%) 56.275% /1);"
<!-- - When a user messages you, you should get a notification. On the server list that user profile picture should be their above all servers. right under the discord and above the server-separator. With a red dot next to it. If you get a private dm you should hear the ping sound also -->
<!-- - We should play a sound (the ping sound) when a user mentions you or you recieve a private message. -->
<!-- - In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages. -->
<!-- - Owners should be able to delete anyones message in the server. -->
<!-- - When i share my screen using the Share Screen button thats in our side bar with the disconnect button i dont hear the sharing screen sound like i started sharing. I only hear it when i use the screenshare button in the voice stage modal.
- Add audio to screenshare -->
<!-- - Figure out why audio is shit. -->
- Fix green status not updating correctly
<!-- - Move people between voice channels. -->
<!-- - Allow copy paste of images using CTRL + V in the message box to attach an iamge. -->
<!-- - If you go afk for 5min switch to idel channel -->
<!-- - Add server muting. Forcing user to mute. -->
<!-- - Allow users to mute other users for themself only. I want to be able to allow users to mute other users for themself only and no one else. So if we click the button button in the popup that we get for when we right click on a user and click mute we will mute their voice audio. Can we also update that menu i have a snippit server mute setting snippit.txt inside the discord-html-copy folder. Where they have a checkbox that shows when that mute is on or off. Also when we mute someone we put the personal_mute.svg icon on them. If they are muted themself we show this icon rather than the mute.svg icon. -->
<!-- - Independient voice volumes per user. -->
<!-- - We have it so if a user is in a voice channel on the memebers list it shows a status as "In voice" with a icon. Can we do the same when they are streaming. Where its the streaming icon and says "Sharing their screen" We will use the sharing.svg icon. -->
# Future
<!-- - Can we allow users to add custom join sounds. Right now we have a default join sound. Can we make it so users can upload their own join sound? In their user settings. They can upload a audio file and it will be used as their join sound instead of the default join sound. -->
<!-- - Make people type passwords twice to make sure they dont mess up typing their password for registration. -->
<!-- How can we save user preferences for the app like individual user volumes, the position and size they have the floating stream popout, if they have categories collaped, the last channel they were in so we can open that channel when they open the app, etc. -->
<!-- - Lets make it so we can upload a custom image for the server that will show on the sidebar. Make the image editing like how we do it for avatars but instead of a circle that we have to show users cut off its a square with a border radius, match it to the boarder radius of the server-item-wrapper -->
- 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.
<!-- - WHen i go idle and then come back sometimes it dosent show im online again, or maybe im idle and close the app and relaunch it, it says im idle still and i have to manualy change to online. -->
- Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again.
- Lets make a Popup menu on chat input to paste image or anything from clipboard. So its on option called "Paste". Should only popup if you right click the chat input box.
- Add photo / video albums like Commit https://commet.chat/
<!-- - If app is in the background or minimized it dosent show im online shows im offline -->
when i do filter image it has some other stuff like a .html file and a video file. In the search results but we shouldent show non images in the image filter. Videos should have its own filter and .html and other files should go under the file filter, but images should not go under the file filter. We should also show the video and file attachment like how we show it in the chat.
<!-- - When someone is sharing their screen and we are getting their audio lets ignore any sounds coming from our app. -->
<!-- - Custom intro sounds repeat when another user joins the chat even though the user with that custom sound didnt join and is already in voice chat.
- In the user options when you right click on a user in voice we should have the option to disconnect user from voice channel -->
<!-- - Can we Add volume slider for screenshare audio, and add a fullscreen button for screenshare, where we fullscreen the voice stage. -->
- Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again.

View File

@@ -603,6 +603,43 @@ app.whenReady().then(async () => {
});
});
// --- Search DB file storage ---
const SEARCH_DIR = path.join(app.getPath('userData'), 'search');
ipcMain.handle('search-db-load', (event, userId) => {
try {
const filePath = path.join(SEARCH_DIR, `search-${userId}.db.enc`);
if (!fs.existsSync(filePath)) return null;
return new Uint8Array(fs.readFileSync(filePath));
} catch (err) {
console.error('Search DB load error:', err.message);
return null;
}
});
ipcMain.handle('search-db-save', (event, userId, data) => {
try {
if (!fs.existsSync(SEARCH_DIR)) fs.mkdirSync(SEARCH_DIR, { recursive: true });
const filePath = path.join(SEARCH_DIR, `search-${userId}.db.enc`);
fs.writeFileSync(filePath, Buffer.from(data));
return true;
} catch (err) {
console.error('Search DB save error:', err.message);
return false;
}
});
ipcMain.handle('search-db-clear', (event, userId) => {
try {
const filePath = path.join(SEARCH_DIR, `search-${userId}.db.enc`);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
return true;
} catch (err) {
console.error('Search DB clear error:', err.message);
return false;
}
});
// AFK voice channel: expose system idle time to renderer
ipcMain.handle('get-system-idle-time', () => powerMonitor.getSystemIdleTime());

View File

@@ -42,6 +42,12 @@ contextBridge.exposeInMainWorld('sessionPersistence', {
clear: () => ipcRenderer.invoke('clear-session'),
});
contextBridge.exposeInMainWorld('searchStorage', {
load: (userId) => ipcRenderer.invoke('search-db-load', userId),
save: (userId, data) => ipcRenderer.invoke('search-db-save', userId, data),
clear: (userId) => ipcRenderer.invoke('search-db-clear', userId),
});
contextBridge.exposeInMainWorld('idleAPI', {
onIdleStateChanged: (callback) => ipcRenderer.on('idle-state-changed', (_event, data) => callback(data)),
removeIdleStateListener: () => ipcRenderer.removeAllListeners('idle-state-changed'),

View File

@@ -6,6 +6,7 @@ import { PlatformProvider } from '@discord-clone/shared/src/platform';
import App from '@discord-clone/shared/src/App';
import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext';
import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext';
import { SearchProvider } from '@discord-clone/shared/src/contexts/SearchContext';
import { UpdateProvider } from '@discord-clone/shared/src/components/UpdateBanner';
import TitleBar from '@discord-clone/shared/src/components/TitleBar';
import electronPlatform from './platform';
@@ -20,12 +21,14 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<ThemeProvider>
<UpdateProvider>
<ConvexProvider client={convex}>
<VoiceProvider>
<TitleBar />
<HashRouter>
<App />
</HashRouter>
</VoiceProvider>
<SearchProvider>
<VoiceProvider>
<TitleBar />
<HashRouter>
<App />
</HashRouter>
</VoiceProvider>
</SearchProvider>
</ConvexProvider>
</UpdateProvider>
</ThemeProvider>

View File

@@ -2,6 +2,16 @@
* Electron platform implementation.
* Delegates to the window.* APIs exposed by preload.cjs.
*/
import SearchDatabase from '@discord-clone/shared/src/utils/SearchDatabase';
const searchDB = new SearchDatabase(
window.searchStorage,
{
encryptData: (data, key) => window.cryptoAPI.encryptData(data, key),
decryptData: (ct, key, iv, tag) => window.cryptoAPI.decryptData(ct, key, iv, tag),
}
);
const electronPlatform = {
crypto: {
generateKeys: () => window.cryptoAPI.generateKeys(),
@@ -46,10 +56,12 @@ const electronPlatform = {
updates: {
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
},
searchDB,
features: {
hasWindowControls: true,
hasScreenCapture: true,
hasNativeUpdates: true,
hasSearch: true,
},
};

View File

@@ -6,6 +6,7 @@ import { PlatformProvider } from '@discord-clone/shared/src/platform';
import App from '@discord-clone/shared/src/App';
import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext';
import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext';
import { SearchProvider } from '@discord-clone/shared/src/contexts/SearchContext';
import webPlatform from '@discord-clone/platform-web';
import '@discord-clone/shared/src/styles/themes.css';
import '@discord-clone/shared/src/index.css';
@@ -17,11 +18,13 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<PlatformProvider platform={webPlatform}>
<ThemeProvider>
<ConvexProvider client={convex}>
<VoiceProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</VoiceProvider>
<SearchProvider>
<VoiceProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</VoiceProvider>
</SearchProvider>
</ConvexProvider>
</ThemeProvider>
</PlatformProvider>

View File

@@ -1,383 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.updateStatus = exports.getMyJoinSoundUrl = exports.updateProfile = exports.getPublicKeys = exports.createUserWithProfile = exports.verifyUser = exports.getSalt = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var storageUrl_1 = require("./storageUrl");
function sha256Hex(input) {
return __awaiter(this, void 0, void 0, function () {
var buffer;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, crypto.subtle.digest("SHA-256", new TextEncoder().encode(input))];
case 1:
buffer = _a.sent();
return [2 /*return*/, Array.from(new Uint8Array(buffer))
.map(function (b) { return b.toString(16).padStart(2, "0"); })
.join("")];
}
});
});
}
// Get salt for a username (returns fake salt for non-existent users)
exports.getSalt = (0, server_1.query)({
args: { username: values_1.v.string() },
returns: values_1.v.object({ salt: values_1.v.string() }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var user, fakeSalt;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("userProfiles")
.withIndex("by_username", function (q) { return q.eq("username", args.username); })
.unique()];
case 1:
user = _a.sent();
if (user) {
return [2 /*return*/, { salt: user.clientSalt }];
}
return [4 /*yield*/, sha256Hex("SERVER_SECRET_KEY" + args.username)];
case 2:
fakeSalt = _a.sent();
return [2 /*return*/, { salt: fakeSalt }];
}
});
}); },
});
// Verify user credentials (DAK comparison)
exports.verifyUser = (0, server_1.mutation)({
args: {
username: values_1.v.string(),
dak: values_1.v.string(),
},
returns: values_1.v.union(values_1.v.object({
success: values_1.v.boolean(),
userId: values_1.v.string(),
encryptedMK: values_1.v.string(),
encryptedPrivateKeys: values_1.v.string(),
publicKey: values_1.v.string(),
}), values_1.v.object({ error: values_1.v.string() })),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var user, hashedDAK;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("userProfiles")
.withIndex("by_username", function (q) { return q.eq("username", args.username); })
.unique()];
case 1:
user = _a.sent();
if (!user) {
return [2 /*return*/, { error: "Invalid credentials" }];
}
return [4 /*yield*/, sha256Hex(args.dak)];
case 2:
hashedDAK = _a.sent();
if (hashedDAK === user.hashedAuthKey) {
return [2 /*return*/, {
success: true,
userId: user._id,
encryptedMK: user.encryptedMasterKey,
encryptedPrivateKeys: user.encryptedPrivateKeys,
publicKey: user.publicIdentityKey,
}];
}
return [2 /*return*/, { error: "Invalid credentials" }];
}
});
}); },
});
// Register new user with crypto keys
exports.createUserWithProfile = (0, server_1.mutation)({
args: {
username: values_1.v.string(),
salt: values_1.v.string(),
encryptedMK: values_1.v.string(),
hak: values_1.v.string(),
publicKey: values_1.v.string(),
signingKey: values_1.v.string(),
encryptedPrivateKeys: values_1.v.string(),
inviteCode: values_1.v.optional(values_1.v.string()),
},
returns: values_1.v.union(values_1.v.object({ success: values_1.v.boolean(), userId: values_1.v.string() }), values_1.v.object({ error: values_1.v.string() })),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing, isFirstUser, invite, userId, everyoneRoleId, ownerRoleId, everyoneRole;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("userProfiles")
.withIndex("by_username", function (q) { return q.eq("username", args.username); })
.unique()];
case 1:
existing = _a.sent();
if (existing) {
return [2 /*return*/, { error: "Username taken" }];
}
return [4 /*yield*/, ctx.db.query("userProfiles").first()];
case 2:
isFirstUser = (_a.sent()) === null;
if (!!isFirstUser) return [3 /*break*/, 5];
if (!args.inviteCode) {
return [2 /*return*/, { error: "Invite code required" }];
}
return [4 /*yield*/, ctx.db
.query("invites")
.withIndex("by_code", function (q) { return q.eq("code", args.inviteCode); })
.unique()];
case 3:
invite = _a.sent();
if (!invite) {
return [2 /*return*/, { error: "Invalid invite code" }];
}
if (invite.expiresAt && Date.now() > invite.expiresAt) {
return [2 /*return*/, { error: "Invite expired" }];
}
if (invite.maxUses !== undefined &&
invite.maxUses !== null &&
invite.uses >= invite.maxUses) {
return [2 /*return*/, { error: "Invite max uses reached" }];
}
return [4 /*yield*/, ctx.db.patch(invite._id, { uses: invite.uses + 1 })];
case 4:
_a.sent();
_a.label = 5;
case 5: return [4 /*yield*/, ctx.db.insert("userProfiles", {
username: args.username,
clientSalt: args.salt,
encryptedMasterKey: args.encryptedMK,
hashedAuthKey: args.hak,
publicIdentityKey: args.publicKey,
publicSigningKey: args.signingKey,
encryptedPrivateKeys: args.encryptedPrivateKeys,
isAdmin: isFirstUser,
})];
case 6:
userId = _a.sent();
if (!isFirstUser) return [3 /*break*/, 11];
return [4 /*yield*/, ctx.db.insert("roles", {
name: "@everyone",
color: "#99aab5",
position: 0,
permissions: {
create_invite: true,
embed_links: true,
attach_files: true,
},
isHoist: false,
})];
case 7:
everyoneRoleId = _a.sent();
return [4 /*yield*/, ctx.db.insert("roles", {
name: "Owner",
color: "#e91e63",
position: 100,
permissions: {
manage_channels: true,
manage_roles: true,
manage_messages: true,
create_invite: true,
embed_links: true,
attach_files: true,
},
isHoist: true,
})];
case 8:
ownerRoleId = _a.sent();
return [4 /*yield*/, ctx.db.insert("userRoles", { userId: userId, roleId: everyoneRoleId })];
case 9:
_a.sent();
return [4 /*yield*/, ctx.db.insert("userRoles", { userId: userId, roleId: ownerRoleId })];
case 10:
_a.sent();
return [3 /*break*/, 14];
case 11: return [4 /*yield*/, ctx.db
.query("roles")
.filter(function (q) { return q.eq(q.field("name"), "@everyone"); })
.first()];
case 12:
everyoneRole = _a.sent();
if (!everyoneRole) return [3 /*break*/, 14];
return [4 /*yield*/, ctx.db.insert("userRoles", {
userId: userId,
roleId: everyoneRole._id,
})];
case 13:
_a.sent();
_a.label = 14;
case 14: return [2 /*return*/, { success: true, userId: userId }];
}
});
}); },
});
// Get all users' public keys
exports.getPublicKeys = (0, server_1.query)({
args: {},
returns: values_1.v.array(values_1.v.object({
id: values_1.v.string(),
username: values_1.v.string(),
public_identity_key: values_1.v.string(),
status: values_1.v.optional(values_1.v.string()),
displayName: values_1.v.optional(values_1.v.string()),
avatarUrl: values_1.v.optional(values_1.v.union(values_1.v.string(), values_1.v.null())),
aboutMe: values_1.v.optional(values_1.v.string()),
customStatus: values_1.v.optional(values_1.v.string()),
joinSoundUrl: values_1.v.optional(values_1.v.union(values_1.v.string(), values_1.v.null())),
})),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
var users, results, _i, users_1, u, avatarUrl, joinSoundUrl;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.query("userProfiles").collect()];
case 1:
users = _a.sent();
results = [];
_i = 0, users_1 = users;
_a.label = 2;
case 2:
if (!(_i < users_1.length)) return [3 /*break*/, 8];
u = users_1[_i];
avatarUrl = null;
if (!u.avatarStorageId) return [3 /*break*/, 4];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, u.avatarStorageId)];
case 3:
avatarUrl = _a.sent();
_a.label = 4;
case 4:
joinSoundUrl = null;
if (!u.joinSoundStorageId) return [3 /*break*/, 6];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, u.joinSoundStorageId)];
case 5:
joinSoundUrl = _a.sent();
_a.label = 6;
case 6:
results.push({
id: u._id,
username: u.username,
public_identity_key: u.publicIdentityKey,
status: u.status || "offline",
displayName: u.displayName,
avatarUrl: avatarUrl,
aboutMe: u.aboutMe,
customStatus: u.customStatus,
joinSoundUrl: joinSoundUrl,
});
_a.label = 7;
case 7:
_i++;
return [3 /*break*/, 2];
case 8: return [2 /*return*/, results];
}
});
}); },
});
// Update user profile (aboutMe, avatar, customStatus)
exports.updateProfile = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
displayName: values_1.v.optional(values_1.v.string()),
aboutMe: values_1.v.optional(values_1.v.string()),
avatarStorageId: values_1.v.optional(values_1.v.id("_storage")),
customStatus: values_1.v.optional(values_1.v.string()),
joinSoundStorageId: values_1.v.optional(values_1.v.id("_storage")),
removeJoinSound: values_1.v.optional(values_1.v.boolean()),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var patch;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
patch = {};
if (args.displayName !== undefined)
patch.displayName = args.displayName;
if (args.aboutMe !== undefined)
patch.aboutMe = args.aboutMe;
if (args.avatarStorageId !== undefined)
patch.avatarStorageId = args.avatarStorageId;
if (args.customStatus !== undefined)
patch.customStatus = args.customStatus;
if (args.joinSoundStorageId !== undefined)
patch.joinSoundStorageId = args.joinSoundStorageId;
if (args.removeJoinSound)
patch.joinSoundStorageId = undefined;
return [4 /*yield*/, ctx.db.patch(args.userId, patch)];
case 1:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
// Get the current user's join sound URL
exports.getMyJoinSoundUrl = (0, server_1.query)({
args: { userId: values_1.v.id("userProfiles") },
returns: values_1.v.union(values_1.v.string(), values_1.v.null()),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var user;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.userId)];
case 1:
user = _a.sent();
if (!(user === null || user === void 0 ? void 0 : user.joinSoundStorageId))
return [2 /*return*/, null];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, user.joinSoundStorageId)];
case 2: return [2 /*return*/, _a.sent()];
}
});
}); },
});
// Update user status
exports.updateStatus = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
status: values_1.v.string(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.patch(args.userId, { status: args.status })];
case 1:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});

View File

@@ -108,9 +108,10 @@ export const createUserWithProfile = mutation({
return { error: "Invite code required" };
}
const inviteCode = args.inviteCode!;
const invite = await ctx.db
.query("invites")
.withIndex("by_code", (q) => q.eq("code", args.inviteCode))
.withIndex("by_code", (q) => q.eq("code", inviteCode))
.unique();
if (!invite) {

View File

@@ -1,187 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.reorder = exports.remove = exports.rename = exports.create = exports.list = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
// List all categories ordered by position
exports.list = (0, server_1.query)({
args: {},
returns: values_1.v.array(values_1.v.object({
_id: values_1.v.id("categories"),
_creationTime: values_1.v.number(),
name: values_1.v.string(),
position: values_1.v.number(),
})),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("categories")
.withIndex("by_position")
.collect()];
case 1: return [2 /*return*/, _a.sent()];
}
});
}); },
});
// Create a new category
exports.create = (0, server_1.mutation)({
args: { name: values_1.v.string() },
returns: values_1.v.object({ id: values_1.v.id("categories") }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var name, all, maxPos, id;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
name = args.name.trim();
if (!name)
throw new Error("Category name required");
return [4 /*yield*/, ctx.db.query("categories").collect()];
case 1:
all = _a.sent();
maxPos = all.reduce(function (max, c) { return Math.max(max, c.position); }, -1000);
return [4 /*yield*/, ctx.db.insert("categories", {
name: name,
position: maxPos + 1000,
})];
case 2:
id = _a.sent();
return [2 /*return*/, { id: id }];
}
});
}); },
});
// Rename a category
exports.rename = (0, server_1.mutation)({
args: {
id: values_1.v.id("categories"),
name: values_1.v.string(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var name, cat;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
name = args.name.trim();
if (!name)
throw new Error("Category name required");
return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
cat = _a.sent();
if (!cat)
throw new Error("Category not found");
return [4 /*yield*/, ctx.db.patch(args.id, { name: name })];
case 2:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
// Delete a category (moves its channels to uncategorized)
exports.remove = (0, server_1.mutation)({
args: { id: values_1.v.id("categories") },
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var cat, channels, _i, channels_1, ch;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
cat = _a.sent();
if (!cat)
throw new Error("Category not found");
return [4 /*yield*/, ctx.db
.query("channels")
.withIndex("by_category", function (q) { return q.eq("categoryId", args.id); })
.collect()];
case 2:
channels = _a.sent();
_i = 0, channels_1 = channels;
_a.label = 3;
case 3:
if (!(_i < channels_1.length)) return [3 /*break*/, 6];
ch = channels_1[_i];
return [4 /*yield*/, ctx.db.patch(ch._id, { categoryId: undefined })];
case 4:
_a.sent();
_a.label = 5;
case 5:
_i++;
return [3 /*break*/, 3];
case 6: return [4 /*yield*/, ctx.db.delete(args.id)];
case 7:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
// Batch reorder categories
exports.reorder = (0, server_1.mutation)({
args: {
updates: values_1.v.array(values_1.v.object({
id: values_1.v.id("categories"),
position: values_1.v.number(),
})),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var _i, _a, u;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_i = 0, _a = args.updates;
_b.label = 1;
case 1:
if (!(_i < _a.length)) return [3 /*break*/, 4];
u = _a[_i];
return [4 /*yield*/, ctx.db.patch(u.id, { position: u.position })];
case 2:
_b.sent();
_b.label = 3;
case 3:
_i++;
return [3 /*break*/, 1];
case 4: return [2 /*return*/, null];
}
});
}); },
});

View File

@@ -1,138 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getKeysForUser = exports.uploadKeys = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
// Batch upsert encrypted key bundles
exports.uploadKeys = (0, server_1.mutation)({
args: {
keys: values_1.v.array(values_1.v.object({
channelId: values_1.v.id("channels"),
userId: values_1.v.id("userProfiles"),
encryptedKeyBundle: values_1.v.string(),
keyVersion: values_1.v.number(),
})),
},
returns: values_1.v.object({ success: values_1.v.boolean(), count: values_1.v.number() }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var _loop_1, _i, _a, keyData;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_loop_1 = function (keyData) {
var existing;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
if (!keyData.channelId || !keyData.userId || !keyData.encryptedKeyBundle) {
return [2 /*return*/, "continue"];
}
return [4 /*yield*/, ctx.db
.query("channelKeys")
.withIndex("by_channel_and_user", function (q) {
return q.eq("channelId", keyData.channelId).eq("userId", keyData.userId);
})
.unique()];
case 1:
existing = _c.sent();
if (!existing) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.patch(existing._id, {
encryptedKeyBundle: keyData.encryptedKeyBundle,
keyVersion: keyData.keyVersion,
})];
case 2:
_c.sent();
return [3 /*break*/, 5];
case 3: return [4 /*yield*/, ctx.db.insert("channelKeys", {
channelId: keyData.channelId,
userId: keyData.userId,
encryptedKeyBundle: keyData.encryptedKeyBundle,
keyVersion: keyData.keyVersion,
})];
case 4:
_c.sent();
_c.label = 5;
case 5: return [2 /*return*/];
}
});
};
_i = 0, _a = args.keys;
_b.label = 1;
case 1:
if (!(_i < _a.length)) return [3 /*break*/, 4];
keyData = _a[_i];
return [5 /*yield**/, _loop_1(keyData)];
case 2:
_b.sent();
_b.label = 3;
case 3:
_i++;
return [3 /*break*/, 1];
case 4: return [2 /*return*/, { success: true, count: args.keys.length }];
}
});
}); },
});
// Get user's encrypted key bundles (reactive!)
exports.getKeysForUser = (0, server_1.query)({
args: { userId: values_1.v.id("userProfiles") },
returns: values_1.v.array(values_1.v.object({
channel_id: values_1.v.id("channels"),
encrypted_key_bundle: values_1.v.string(),
key_version: values_1.v.number(),
})),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var keys;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("channelKeys")
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
.collect()];
case 1:
keys = _a.sent();
return [2 /*return*/, keys.map(function (k) { return ({
channel_id: k.channelId,
encrypted_key_bundle: k.encryptedKeyBundle,
key_version: k.keyVersion,
}); })];
}
});
}); },
});

View File

@@ -1,390 +0,0 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.remove = exports.reorderChannels = exports.moveChannel = exports.rename = exports.updateTopic = exports.create = exports.get = exports.list = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var api_1 = require("./_generated/api");
function deleteByChannel(ctx, table, channelId) {
return __awaiter(this, void 0, void 0, function () {
var docs, _i, docs_1, doc;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.query(table)
.withIndex("by_channel", function (q) { return q.eq("channelId", channelId); })
.collect()];
case 1:
docs = _a.sent();
_i = 0, docs_1 = docs;
_a.label = 2;
case 2:
if (!(_i < docs_1.length)) return [3 /*break*/, 5];
doc = docs_1[_i];
return [4 /*yield*/, ctx.db.delete(doc._id)];
case 3:
_a.sent();
_a.label = 4;
case 4:
_i++;
return [3 /*break*/, 2];
case 5: return [2 /*return*/];
}
});
});
}
// List all non-DM channels
exports.list = (0, server_1.query)({
args: {},
returns: values_1.v.array(values_1.v.object({
_id: values_1.v.id("channels"),
_creationTime: values_1.v.number(),
name: values_1.v.string(),
type: values_1.v.string(),
categoryId: values_1.v.optional(values_1.v.id("categories")),
topic: values_1.v.optional(values_1.v.string()),
position: values_1.v.optional(values_1.v.number()),
})),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
var channels;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.query("channels").collect()];
case 1:
channels = _a.sent();
return [2 /*return*/, channels
.filter(function (c) { return c.type !== "dm"; })
.sort(function (a, b) { var _a, _b; return ((_a = a.position) !== null && _a !== void 0 ? _a : 0) - ((_b = b.position) !== null && _b !== void 0 ? _b : 0) || a.name.localeCompare(b.name); })];
}
});
}); },
});
// Get single channel by ID
exports.get = (0, server_1.query)({
args: { id: values_1.v.id("channels") },
returns: values_1.v.union(values_1.v.object({
_id: values_1.v.id("channels"),
_creationTime: values_1.v.number(),
name: values_1.v.string(),
type: values_1.v.string(),
categoryId: values_1.v.optional(values_1.v.id("categories")),
topic: values_1.v.optional(values_1.v.string()),
position: values_1.v.optional(values_1.v.number()),
}), values_1.v.null()),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.id)];
case 1: return [2 /*return*/, _a.sent()];
}
});
}); },
});
// Create new channel
exports.create = (0, server_1.mutation)({
args: {
name: values_1.v.string(),
type: values_1.v.optional(values_1.v.string()),
categoryId: values_1.v.optional(values_1.v.id("categories")),
topic: values_1.v.optional(values_1.v.string()),
position: values_1.v.optional(values_1.v.number()),
},
returns: values_1.v.object({ id: values_1.v.id("channels") }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing, position, allChannels, sameCategory, maxPos, id;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!args.name.trim()) {
throw new Error("Channel name required");
}
return [4 /*yield*/, ctx.db
.query("channels")
.withIndex("by_name", function (q) { return q.eq("name", args.name); })
.unique()];
case 1:
existing = _a.sent();
if (existing) {
throw new Error("Channel already exists");
}
position = args.position;
if (!(position === undefined)) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.query("channels").collect()];
case 2:
allChannels = _a.sent();
sameCategory = allChannels.filter(function (c) { return c.categoryId === args.categoryId && c.type !== "dm"; });
maxPos = sameCategory.reduce(function (max, c) { var _a; return Math.max(max, (_a = c.position) !== null && _a !== void 0 ? _a : 0); }, -1000);
position = maxPos + 1000;
_a.label = 3;
case 3: return [4 /*yield*/, ctx.db.insert("channels", {
name: args.name,
type: args.type || "text",
categoryId: args.categoryId,
topic: args.topic,
position: position,
})];
case 4:
id = _a.sent();
return [2 /*return*/, { id: id }];
}
});
}); },
});
// Update channel topic
exports.updateTopic = (0, server_1.mutation)({
args: {
id: values_1.v.id("channels"),
topic: values_1.v.string(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var channel;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
channel = _a.sent();
if (!channel)
throw new Error("Channel not found");
return [4 /*yield*/, ctx.db.patch(args.id, { topic: args.topic })];
case 2:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
// Rename channel
exports.rename = (0, server_1.mutation)({
args: {
id: values_1.v.id("channels"),
name: values_1.v.string(),
},
returns: values_1.v.object({
_id: values_1.v.id("channels"),
_creationTime: values_1.v.number(),
name: values_1.v.string(),
type: values_1.v.string(),
categoryId: values_1.v.optional(values_1.v.id("categories")),
topic: values_1.v.optional(values_1.v.string()),
position: values_1.v.optional(values_1.v.number()),
}),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var channel;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!args.name.trim()) {
throw new Error("Name required");
}
return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
channel = _a.sent();
if (!channel) {
throw new Error("Channel not found");
}
return [4 /*yield*/, ctx.db.patch(args.id, { name: args.name })];
case 2:
_a.sent();
return [2 /*return*/, __assign(__assign({}, channel), { name: args.name })];
}
});
}); },
});
// Move a channel to a different category with a new position
exports.moveChannel = (0, server_1.mutation)({
args: {
id: values_1.v.id("channels"),
categoryId: values_1.v.optional(values_1.v.id("categories")),
position: values_1.v.number(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var channel;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
channel = _a.sent();
if (!channel)
throw new Error("Channel not found");
return [4 /*yield*/, ctx.db.patch(args.id, {
categoryId: args.categoryId,
position: args.position,
})];
case 2:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
// Batch reorder channels
exports.reorderChannels = (0, server_1.mutation)({
args: {
updates: values_1.v.array(values_1.v.object({
id: values_1.v.id("channels"),
categoryId: values_1.v.optional(values_1.v.id("categories")),
position: values_1.v.number(),
})),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var _i, _a, u;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_i = 0, _a = args.updates;
_b.label = 1;
case 1:
if (!(_i < _a.length)) return [3 /*break*/, 4];
u = _a[_i];
return [4 /*yield*/, ctx.db.patch(u.id, {
categoryId: u.categoryId,
position: u.position,
})];
case 2:
_b.sent();
_b.label = 3;
case 3:
_i++;
return [3 /*break*/, 1];
case 4: return [2 /*return*/, null];
}
});
}); },
});
// Delete channel + cascade messages and keys
exports.remove = (0, server_1.mutation)({
args: { id: values_1.v.id("channels") },
returns: values_1.v.object({ success: values_1.v.boolean() }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var channel, messages, _loop_1, _i, messages_1, msg;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
channel = _a.sent();
if (!channel) {
throw new Error("Channel not found");
}
return [4 /*yield*/, ctx.db
.query("messages")
.withIndex("by_channel", function (q) { return q.eq("channelId", args.id); })
.collect()];
case 2:
messages = _a.sent();
_loop_1 = function (msg) {
var reactions, _b, reactions_1, r;
return __generator(this, function (_c) {
switch (_c.label) {
case 0: return [4 /*yield*/, ctx.db
.query("messageReactions")
.withIndex("by_message", function (q) { return q.eq("messageId", msg._id); })
.collect()];
case 1:
reactions = _c.sent();
_b = 0, reactions_1 = reactions;
_c.label = 2;
case 2:
if (!(_b < reactions_1.length)) return [3 /*break*/, 5];
r = reactions_1[_b];
return [4 /*yield*/, ctx.db.delete(r._id)];
case 3:
_c.sent();
_c.label = 4;
case 4:
_b++;
return [3 /*break*/, 2];
case 5: return [4 /*yield*/, ctx.db.delete(msg._id)];
case 6:
_c.sent();
return [2 /*return*/];
}
});
};
_i = 0, messages_1 = messages;
_a.label = 3;
case 3:
if (!(_i < messages_1.length)) return [3 /*break*/, 6];
msg = messages_1[_i];
return [5 /*yield**/, _loop_1(msg)];
case 4:
_a.sent();
_a.label = 5;
case 5:
_i++;
return [3 /*break*/, 3];
case 6: return [4 /*yield*/, deleteByChannel(ctx, "channelKeys", args.id)];
case 7:
_a.sent();
return [4 /*yield*/, deleteByChannel(ctx, "dmParticipants", args.id)];
case 8:
_a.sent();
return [4 /*yield*/, deleteByChannel(ctx, "typingIndicators", args.id)];
case 9:
_a.sent();
return [4 /*yield*/, deleteByChannel(ctx, "voiceStates", args.id)];
case 10:
_a.sent();
return [4 /*yield*/, deleteByChannel(ctx, "channelReadState", args.id)];
case 11:
_a.sent();
// Clear AFK setting if this channel was the AFK channel
return [4 /*yield*/, ctx.runMutation(api_1.internal.serverSettings.clearAfkChannel, { channelId: args.id })];
case 12:
// Clear AFK setting if this channel was the AFK channel
_a.sent();
return [4 /*yield*/, ctx.db.delete(args.id)];
case 13:
_a.sent();
return [2 /*return*/, { success: true }];
}
});
}); },
});

View File

@@ -1,7 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var server_1 = require("convex/server");
var convex_config_js_1 = require("@convex-dev/presence/convex.config.js");
var app = (0, server_1.defineApp)();
app.use(convex_config_js_1.default);
exports.default = app;

View File

@@ -1,158 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.listDMs = exports.openDM = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var storageUrl_1 = require("./storageUrl");
exports.openDM = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
targetUserId: values_1.v.id("userProfiles"),
},
returns: values_1.v.object({
channelId: values_1.v.id("channels"),
created: values_1.v.boolean(),
}),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var sorted, dmName, existing, channelId;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (args.userId === args.targetUserId) {
throw new Error("Cannot DM yourself");
}
sorted = [args.userId, args.targetUserId].sort();
dmName = "dm-".concat(sorted[0], "-").concat(sorted[1]);
return [4 /*yield*/, ctx.db
.query("channels")
.withIndex("by_name", function (q) { return q.eq("name", dmName); })
.unique()];
case 1:
existing = _a.sent();
if (existing) {
return [2 /*return*/, { channelId: existing._id, created: false }];
}
return [4 /*yield*/, ctx.db.insert("channels", {
name: dmName,
type: "dm",
})];
case 2:
channelId = _a.sent();
return [4 /*yield*/, Promise.all([
ctx.db.insert("dmParticipants", { channelId: channelId, userId: args.userId }),
ctx.db.insert("dmParticipants", { channelId: channelId, userId: args.targetUserId }),
])];
case 3:
_a.sent();
return [2 /*return*/, { channelId: channelId, created: true }];
}
});
}); },
});
exports.listDMs = (0, server_1.query)({
args: { userId: values_1.v.id("userProfiles") },
returns: values_1.v.array(values_1.v.object({
channel_id: values_1.v.id("channels"),
channel_name: values_1.v.string(),
other_user_id: values_1.v.string(),
other_username: values_1.v.string(),
other_user_status: values_1.v.optional(values_1.v.string()),
other_user_avatar_url: values_1.v.optional(values_1.v.union(values_1.v.string(), values_1.v.null())),
})),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var myParticipations, results;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("dmParticipants")
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
.collect()];
case 1:
myParticipations = _a.sent();
return [4 /*yield*/, Promise.all(myParticipations.map(function (part) { return __awaiter(void 0, void 0, void 0, function () {
var channel, otherParts, otherPart, otherUser, avatarUrl, _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0: return [4 /*yield*/, ctx.db.get(part.channelId)];
case 1:
channel = _b.sent();
if (!channel || channel.type !== "dm")
return [2 /*return*/, null];
return [4 /*yield*/, ctx.db
.query("dmParticipants")
.withIndex("by_channel", function (q) { return q.eq("channelId", part.channelId); })
.collect()];
case 2:
otherParts = _b.sent();
otherPart = otherParts.find(function (p) { return p.userId !== args.userId; });
if (!otherPart)
return [2 /*return*/, null];
return [4 /*yield*/, ctx.db.get(otherPart.userId)];
case 3:
otherUser = _b.sent();
if (!otherUser)
return [2 /*return*/, null];
if (!otherUser.avatarStorageId) return [3 /*break*/, 5];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, otherUser.avatarStorageId)];
case 4:
_a = _b.sent();
return [3 /*break*/, 6];
case 5:
_a = null;
_b.label = 6;
case 6:
avatarUrl = _a;
return [2 /*return*/, {
channel_id: part.channelId,
channel_name: channel.name,
other_user_id: otherUser._id,
other_username: otherUser.username,
other_user_status: otherUser.status || "offline",
other_user_avatar_url: avatarUrl,
}];
}
});
}); }))];
case 2:
results = _a.sent();
return [2 /*return*/, results.filter(function (r) { return r !== null; })];
}
});
}); },
});

View File

@@ -1,71 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getFileUrl = exports.generateUploadUrl = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var storageUrl_1 = require("./storageUrl");
// Generate upload URL for client-side uploads
exports.generateUploadUrl = (0, server_1.mutation)({
args: {},
returns: values_1.v.string(),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
var url;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.storage.generateUploadUrl()];
case 1:
url = _a.sent();
return [2 /*return*/, (0, storageUrl_1.rewriteToPublicUrl)(url)];
}
});
}); },
});
// Get file URL from storage ID
exports.getFileUrl = (0, server_1.query)({
args: { storageId: values_1.v.id("_storage") },
returns: values_1.v.union(values_1.v.string(), values_1.v.null()),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, args.storageId)];
case 1: return [2 /*return*/, _a.sent()];
}
});
}); },
});

View File

@@ -1,86 +0,0 @@
"use strict";
"use node";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.categories = exports.search = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
// Search GIFs via Tenor API
exports.search = (0, server_1.action)({
args: {
q: values_1.v.string(),
limit: values_1.v.optional(values_1.v.number()),
},
returns: values_1.v.any(),
handler: function (_ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var apiKey, limit, url, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
apiKey = process.env.TENOR_API_KEY;
if (!apiKey) {
console.warn("TENOR_API_KEY missing");
return [2 /*return*/, { results: [] }];
}
limit = args.limit || 8;
url = "https://tenor.googleapis.com/v2/search?q=".concat(encodeURIComponent(args.q), "&key=").concat(apiKey, "&limit=").concat(limit);
return [4 /*yield*/, fetch(url)];
case 1:
response = _a.sent();
if (!response.ok) {
console.error("Tenor API Error:", response.statusText);
return [2 /*return*/, { results: [] }];
}
return [4 /*yield*/, response.json()];
case 2: return [2 /*return*/, _a.sent()];
}
});
}); },
});
// Get GIF categories
exports.categories = (0, server_1.action)({
args: {},
returns: values_1.v.any(),
handler: function () { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
// Return static categories (same as the JSON file in backend)
// These are loaded from the frontend data file
return [2 /*return*/, { categories: [] }];
});
}); },
});

View File

@@ -1,131 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.revoke = exports.use = exports.create = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
// Create invite with encrypted payload
exports.create = (0, server_1.mutation)({
args: {
code: values_1.v.string(),
encryptedPayload: values_1.v.string(),
createdBy: values_1.v.id("userProfiles"),
maxUses: values_1.v.optional(values_1.v.number()),
expiresAt: values_1.v.optional(values_1.v.number()),
keyVersion: values_1.v.number(),
},
returns: values_1.v.object({ success: values_1.v.boolean() }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.insert("invites", {
code: args.code,
encryptedPayload: args.encryptedPayload,
createdBy: args.createdBy,
maxUses: args.maxUses,
uses: 0,
expiresAt: args.expiresAt,
keyVersion: args.keyVersion,
})];
case 1:
_a.sent();
return [2 /*return*/, { success: true }];
}
});
}); },
});
// Fetch and validate invite (returns encrypted payload)
exports.use = (0, server_1.query)({
args: { code: values_1.v.string() },
returns: values_1.v.union(values_1.v.object({
encryptedPayload: values_1.v.string(),
keyVersion: values_1.v.number(),
}), values_1.v.object({ error: values_1.v.string() })),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var invite;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("invites")
.withIndex("by_code", function (q) { return q.eq("code", args.code); })
.unique()];
case 1:
invite = _a.sent();
if (!invite) {
return [2 /*return*/, { error: "Invite not found" }];
}
if (invite.expiresAt && Date.now() > invite.expiresAt) {
return [2 /*return*/, { error: "Invite expired" }];
}
if (invite.maxUses !== undefined &&
invite.maxUses !== null &&
invite.uses >= invite.maxUses) {
return [2 /*return*/, { error: "Invite max uses reached" }];
}
return [2 /*return*/, {
encryptedPayload: invite.encryptedPayload,
keyVersion: invite.keyVersion,
}];
}
});
}); },
});
// Revoke invite
exports.revoke = (0, server_1.mutation)({
args: { code: values_1.v.string() },
returns: values_1.v.object({ success: values_1.v.boolean() }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var invite;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("invites")
.withIndex("by_code", function (q) { return q.eq("code", args.code); })
.unique()];
case 1:
invite = _a.sent();
if (!invite) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.delete(invite._id)];
case 2:
_a.sent();
_a.label = 3;
case 3: return [2 /*return*/, { success: true }];
}
});
}); },
});

View File

@@ -1,139 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getChannelMembers = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var storageUrl_1 = require("./storageUrl");
exports.getChannelMembers = (0, server_1.query)({
args: {
channelId: values_1.v.id("channels"),
},
returns: values_1.v.any(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var channelKeyDocs, seenUsers, members, _loop_1, _i, channelKeyDocs_1, doc;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("channelKeys")
.withIndex("by_channel", function (q) { return q.eq("channelId", args.channelId); })
.collect()];
case 1:
channelKeyDocs = _a.sent();
seenUsers = new Set();
members = [];
_loop_1 = function (doc) {
var odId, user, userRoleDocs, roles, _b, userRoleDocs_1, ur, role, avatarUrl;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
odId = doc.userId.toString();
if (seenUsers.has(odId))
return [2 /*return*/, "continue"];
seenUsers.add(odId);
return [4 /*yield*/, ctx.db.get(doc.userId)];
case 1:
user = _c.sent();
if (!user)
return [2 /*return*/, "continue"];
return [4 /*yield*/, ctx.db
.query("userRoles")
.withIndex("by_user", function (q) { return q.eq("userId", doc.userId); })
.collect()];
case 2:
userRoleDocs = _c.sent();
roles = [];
_b = 0, userRoleDocs_1 = userRoleDocs;
_c.label = 3;
case 3:
if (!(_b < userRoleDocs_1.length)) return [3 /*break*/, 6];
ur = userRoleDocs_1[_b];
return [4 /*yield*/, ctx.db.get(ur.roleId)];
case 4:
role = _c.sent();
if (role) {
roles.push({
id: role._id,
name: role.name,
color: role.color,
position: role.position,
isHoist: role.isHoist,
});
}
_c.label = 5;
case 5:
_b++;
return [3 /*break*/, 3];
case 6:
avatarUrl = null;
if (!user.avatarStorageId) return [3 /*break*/, 8];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, user.avatarStorageId)];
case 7:
avatarUrl = _c.sent();
_c.label = 8;
case 8:
members.push({
id: user._id,
username: user.username,
status: user.status || "offline",
roles: roles.sort(function (a, b) { return b.position - a.position; }),
avatarUrl: avatarUrl,
aboutMe: user.aboutMe,
customStatus: user.customStatus,
});
return [2 /*return*/];
}
});
};
_i = 0, channelKeyDocs_1 = channelKeyDocs;
_a.label = 2;
case 2:
if (!(_i < channelKeyDocs_1.length)) return [3 /*break*/, 5];
doc = channelKeyDocs_1[_i];
return [5 /*yield**/, _loop_1(doc)];
case 3:
_a.sent();
_a.label = 4;
case 4:
_i++;
return [3 /*break*/, 2];
case 5: return [2 /*return*/, members];
}
});
}); },
});

View File

@@ -62,3 +62,25 @@ export const getChannelMembers = query({
return members;
},
});
export const listAll = query({
args: {},
returns: v.any(),
handler: async (ctx) => {
const users = await ctx.db.query("userProfiles").collect();
const results = [];
for (const user of users) {
let avatarUrl: string | null = null;
if (user.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
}
results.push({
id: user._id,
username: user.username,
status: user.status || "offline",
avatarUrl,
});
}
return results;
},
});

View File

@@ -1,300 +0,0 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.remove = exports.listPinned = exports.pin = exports.edit = exports.send = exports.list = void 0;
var server_1 = require("./_generated/server");
var server_2 = require("convex/server");
var values_1 = require("convex/values");
var storageUrl_1 = require("./storageUrl");
var roles_1 = require("./roles");
function enrichMessage(ctx, msg, userId) {
return __awaiter(this, void 0, void 0, function () {
var sender, avatarUrl, reactionDocs, reactions, _i, reactionDocs_1, r, entry, replyToUsername, replyToContent, replyToNonce, replyToAvatarUrl, repliedMsg, repliedSender;
var _a;
var _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0: return [4 /*yield*/, ctx.db.get(msg.senderId)];
case 1:
sender = _c.sent();
avatarUrl = null;
if (!(sender === null || sender === void 0 ? void 0 : sender.avatarStorageId)) return [3 /*break*/, 3];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, sender.avatarStorageId)];
case 2:
avatarUrl = _c.sent();
_c.label = 3;
case 3: return [4 /*yield*/, ctx.db
.query("messageReactions")
.withIndex("by_message", function (q) { return q.eq("messageId", msg._id); })
.collect()];
case 4:
reactionDocs = _c.sent();
reactions = {};
for (_i = 0, reactionDocs_1 = reactionDocs; _i < reactionDocs_1.length; _i++) {
r = reactionDocs_1[_i];
entry = ((_a = reactions[_b = r.emoji]) !== null && _a !== void 0 ? _a : (reactions[_b] = { count: 0, me: false }));
entry.count++;
if (userId && r.userId === userId) {
entry.me = true;
}
}
replyToUsername = null;
replyToContent = null;
replyToNonce = null;
replyToAvatarUrl = null;
if (!msg.replyTo) return [3 /*break*/, 8];
return [4 /*yield*/, ctx.db.get(msg.replyTo)];
case 5:
repliedMsg = _c.sent();
if (!repliedMsg) return [3 /*break*/, 8];
return [4 /*yield*/, ctx.db.get(repliedMsg.senderId)];
case 6:
repliedSender = _c.sent();
replyToUsername = (repliedSender === null || repliedSender === void 0 ? void 0 : repliedSender.username) || "Unknown";
replyToContent = repliedMsg.ciphertext;
replyToNonce = repliedMsg.nonce;
if (!(repliedSender === null || repliedSender === void 0 ? void 0 : repliedSender.avatarStorageId)) return [3 /*break*/, 8];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, repliedSender.avatarStorageId)];
case 7:
replyToAvatarUrl = _c.sent();
_c.label = 8;
case 8: return [2 /*return*/, {
id: msg._id,
channel_id: msg.channelId,
sender_id: msg.senderId,
ciphertext: msg.ciphertext,
nonce: msg.nonce,
signature: msg.signature,
key_version: msg.keyVersion,
created_at: new Date(msg._creationTime).toISOString(),
username: (sender === null || sender === void 0 ? void 0 : sender.username) || "Unknown",
public_signing_key: (sender === null || sender === void 0 ? void 0 : sender.publicSigningKey) || "",
avatarUrl: avatarUrl,
reactions: Object.keys(reactions).length > 0 ? reactions : null,
replyToId: msg.replyTo || null,
replyToUsername: replyToUsername,
replyToContent: replyToContent,
replyToNonce: replyToNonce,
replyToAvatarUrl: replyToAvatarUrl,
editedAt: msg.editedAt || null,
pinned: msg.pinned || false,
}];
}
});
});
}
exports.list = (0, server_1.query)({
args: {
paginationOpts: server_2.paginationOptsValidator,
channelId: values_1.v.id("channels"),
userId: values_1.v.optional(values_1.v.id("userProfiles")),
},
returns: values_1.v.any(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var result, enrichedPage;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("messages")
.withIndex("by_channel", function (q) { return q.eq("channelId", args.channelId); })
.order("desc")
.paginate(args.paginationOpts)];
case 1:
result = _a.sent();
return [4 /*yield*/, Promise.all(result.page.map(function (msg) { return enrichMessage(ctx, msg, args.userId); }))];
case 2:
enrichedPage = _a.sent();
return [2 /*return*/, __assign(__assign({}, result), { page: enrichedPage })];
}
});
}); },
});
exports.send = (0, server_1.mutation)({
args: {
channelId: values_1.v.id("channels"),
senderId: values_1.v.id("userProfiles"),
ciphertext: values_1.v.string(),
nonce: values_1.v.string(),
signature: values_1.v.string(),
keyVersion: values_1.v.number(),
replyTo: values_1.v.optional(values_1.v.id("messages")),
},
returns: values_1.v.object({ id: values_1.v.id("messages") }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var id;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.insert("messages", {
channelId: args.channelId,
senderId: args.senderId,
ciphertext: args.ciphertext,
nonce: args.nonce,
signature: args.signature,
keyVersion: args.keyVersion,
replyTo: args.replyTo,
})];
case 1:
id = _a.sent();
return [2 /*return*/, { id: id }];
}
});
}); },
});
exports.edit = (0, server_1.mutation)({
args: {
id: values_1.v.id("messages"),
ciphertext: values_1.v.string(),
nonce: values_1.v.string(),
signature: values_1.v.string(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.patch(args.id, {
ciphertext: args.ciphertext,
nonce: args.nonce,
signature: args.signature,
editedAt: Date.now(),
})];
case 1:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
exports.pin = (0, server_1.mutation)({
args: {
id: values_1.v.id("messages"),
pinned: values_1.v.boolean(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.patch(args.id, { pinned: args.pinned })];
case 1:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
exports.listPinned = (0, server_1.query)({
args: {
channelId: values_1.v.id("channels"),
userId: values_1.v.optional(values_1.v.id("userProfiles")),
},
returns: values_1.v.any(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var pinned;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("messages")
.withIndex("by_channel_pinned", function (q) {
return q.eq("channelId", args.channelId).eq("pinned", true);
})
.collect()];
case 1:
pinned = _a.sent();
return [2 /*return*/, Promise.all(pinned.map(function (msg) { return enrichMessage(ctx, msg, args.userId); }))];
}
});
}); },
});
exports.remove = (0, server_1.mutation)({
args: { id: values_1.v.id("messages"), userId: values_1.v.id("userProfiles") },
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var message, isSender, roles, canManage, reactions, _i, reactions_1, r;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
message = _a.sent();
if (!message)
throw new Error("Message not found");
isSender = message.senderId === args.userId;
if (!!isSender) return [3 /*break*/, 3];
return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.userId)];
case 2:
roles = _a.sent();
canManage = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a.manage_messages; });
if (!canManage) {
throw new Error("Not authorized to delete this message");
}
_a.label = 3;
case 3: return [4 /*yield*/, ctx.db
.query("messageReactions")
.withIndex("by_message", function (q) { return q.eq("messageId", args.id); })
.collect()];
case 4:
reactions = _a.sent();
_i = 0, reactions_1 = reactions;
_a.label = 5;
case 5:
if (!(_i < reactions_1.length)) return [3 /*break*/, 8];
r = reactions_1[_i];
return [4 /*yield*/, ctx.db.delete(r._id)];
case 6:
_a.sent();
_a.label = 7;
case 7:
_i++;
return [3 /*break*/, 5];
case 8: return [4 /*yield*/, ctx.db.delete(args.id)];
case 9:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});

View File

@@ -164,6 +164,42 @@ export const listPinned = query({
},
});
// Slim paginated query for bulk search index rebuilding.
// Skips reactions, avatars, reply resolution — only resolves sender username.
export const fetchBulkPage = query({
args: {
channelId: v.id("channels"),
paginationOpts: paginationOptsValidator,
},
returns: v.any(),
handler: async (ctx, args) => {
const result = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("asc")
.paginate(args.paginationOpts);
const enrichedPage = await Promise.all(
result.page.map(async (msg) => {
const sender = await ctx.db.get(msg.senderId);
return {
id: msg._id,
channel_id: msg.channelId,
sender_id: msg.senderId,
username: sender?.username || "Unknown",
ciphertext: msg.ciphertext,
nonce: msg.nonce,
created_at: new Date(msg._creationTime).toISOString(),
pinned: msg.pinned || false,
replyToId: msg.replyTo || null,
};
})
);
return { ...result, page: enrichedPage };
},
});
export const remove = mutation({
args: { id: v.id("messages"), userId: v.id("userProfiles") },
returns: v.null(),

View File

@@ -1,85 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.disconnect = exports.list = exports.heartbeat = void 0;
var server_1 = require("./_generated/server");
var api_1 = require("./_generated/api");
var values_1 = require("convex/values");
var presence_1 = require("@convex-dev/presence");
var presence = new presence_1.Presence(api_1.components.presence);
exports.heartbeat = (0, server_1.mutation)({
args: {
roomId: values_1.v.string(),
userId: values_1.v.string(),
sessionId: values_1.v.string(),
interval: values_1.v.number(),
},
handler: function (ctx_1, _a) { return __awaiter(void 0, [ctx_1, _a], void 0, function (ctx, _b) {
var roomId = _b.roomId, userId = _b.userId, sessionId = _b.sessionId, interval = _b.interval;
return __generator(this, function (_c) {
switch (_c.label) {
case 0: return [4 /*yield*/, presence.heartbeat(ctx, roomId, userId, sessionId, interval)];
case 1: return [2 /*return*/, _c.sent()];
}
});
}); },
});
exports.list = (0, server_1.query)({
args: { roomToken: values_1.v.string() },
handler: function (ctx_1, _a) { return __awaiter(void 0, [ctx_1, _a], void 0, function (ctx, _b) {
var roomToken = _b.roomToken;
return __generator(this, function (_c) {
switch (_c.label) {
case 0: return [4 /*yield*/, presence.list(ctx, roomToken)];
case 1: return [2 /*return*/, _c.sent()];
}
});
}); },
});
exports.disconnect = (0, server_1.mutation)({
args: { sessionToken: values_1.v.string() },
handler: function (ctx_1, _a) { return __awaiter(void 0, [ctx_1, _a], void 0, function (ctx, _b) {
var sessionToken = _b.sessionToken;
return __generator(this, function (_c) {
switch (_c.label) {
case 0: return [4 /*yield*/, presence.disconnect(ctx, sessionToken)];
case 1: return [2 /*return*/, _c.sent()];
}
});
}); },
});

View File

@@ -1,111 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.remove = exports.add = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
// Add reaction (upsert - no duplicates)
exports.add = (0, server_1.mutation)({
args: {
messageId: values_1.v.id("messages"),
userId: values_1.v.id("userProfiles"),
emoji: values_1.v.string(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("messageReactions")
.withIndex("by_message_user_emoji", function (q) {
return q
.eq("messageId", args.messageId)
.eq("userId", args.userId)
.eq("emoji", args.emoji);
})
.unique()];
case 1:
existing = _a.sent();
if (!!existing) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.insert("messageReactions", {
messageId: args.messageId,
userId: args.userId,
emoji: args.emoji,
})];
case 2:
_a.sent();
_a.label = 3;
case 3: return [2 /*return*/, null];
}
});
}); },
});
// Remove reaction
exports.remove = (0, server_1.mutation)({
args: {
messageId: values_1.v.id("messages"),
userId: values_1.v.id("userProfiles"),
emoji: values_1.v.string(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("messageReactions")
.withIndex("by_message_user_emoji", function (q) {
return q
.eq("messageId", args.messageId)
.eq("userId", args.userId)
.eq("emoji", args.emoji);
})
.unique()];
case 1:
existing = _a.sent();
if (!existing) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.delete(existing._id)];
case 2:
_a.sent();
_a.label = 3;
case 3: return [2 /*return*/, null];
}
});
}); },
});

View File

@@ -1,191 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getLatestMessageTimestamps = exports.getAllReadStates = exports.markRead = exports.getReadState = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
// Get read state for a single channel
exports.getReadState = (0, server_1.query)({
args: {
userId: values_1.v.id("userProfiles"),
channelId: values_1.v.id("channels"),
},
returns: values_1.v.union(values_1.v.object({
lastReadTimestamp: values_1.v.number(),
}), values_1.v.null()),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var state;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("channelReadState")
.withIndex("by_user_and_channel", function (q) {
return q.eq("userId", args.userId).eq("channelId", args.channelId);
})
.unique()];
case 1:
state = _a.sent();
if (!state)
return [2 /*return*/, null];
return [2 /*return*/, { lastReadTimestamp: state.lastReadTimestamp }];
}
});
}); },
});
// Mark a channel as read up to a given timestamp
exports.markRead = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
channelId: values_1.v.id("channels"),
lastReadTimestamp: values_1.v.number(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("channelReadState")
.withIndex("by_user_and_channel", function (q) {
return q.eq("userId", args.userId).eq("channelId", args.channelId);
})
.unique()];
case 1:
existing = _a.sent();
if (!existing) return [3 /*break*/, 4];
if (!(args.lastReadTimestamp > existing.lastReadTimestamp)) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.patch(existing._id, {
lastReadTimestamp: args.lastReadTimestamp,
})];
case 2:
_a.sent();
_a.label = 3;
case 3: return [3 /*break*/, 6];
case 4: return [4 /*yield*/, ctx.db.insert("channelReadState", {
userId: args.userId,
channelId: args.channelId,
lastReadTimestamp: args.lastReadTimestamp,
})];
case 5:
_a.sent();
_a.label = 6;
case 6: return [2 /*return*/, null];
}
});
}); },
});
// Get all read states for a user (used by Sidebar)
exports.getAllReadStates = (0, server_1.query)({
args: {
userId: values_1.v.id("userProfiles"),
},
returns: values_1.v.array(values_1.v.object({
channelId: values_1.v.id("channels"),
lastReadTimestamp: values_1.v.number(),
})),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var states;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("channelReadState")
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
.collect()];
case 1:
states = _a.sent();
return [2 /*return*/, states.map(function (s) { return ({
channelId: s.channelId,
lastReadTimestamp: s.lastReadTimestamp,
}); })];
}
});
}); },
});
// Get the latest message timestamp per channel (used by Sidebar)
exports.getLatestMessageTimestamps = (0, server_1.query)({
args: {
channelIds: values_1.v.array(values_1.v.id("channels")),
},
returns: values_1.v.array(values_1.v.object({
channelId: values_1.v.id("channels"),
latestTimestamp: values_1.v.number(),
})),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var results, _loop_1, _i, _a, channelId;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
results = [];
_loop_1 = function (channelId) {
var latestMsg;
return __generator(this, function (_c) {
switch (_c.label) {
case 0: return [4 /*yield*/, ctx.db
.query("messages")
.withIndex("by_channel", function (q) { return q.eq("channelId", channelId); })
.order("desc")
.first()];
case 1:
latestMsg = _c.sent();
if (latestMsg) {
results.push({
channelId: channelId,
latestTimestamp: Math.floor(latestMsg._creationTime),
});
}
return [2 /*return*/];
}
});
};
_i = 0, _a = args.channelIds;
_b.label = 1;
case 1:
if (!(_i < _a.length)) return [3 /*break*/, 4];
channelId = _a[_i];
return [5 /*yield**/, _loop_1(channelId)];
case 2:
_b.sent();
_b.label = 3;
case 3:
_i++;
return [3 /*break*/, 1];
case 4: return [2 /*return*/, results];
}
});
}); },
});

View File

@@ -1,330 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getMyPermissions = exports.unassign = exports.assign = exports.listMembers = exports.remove = exports.update = exports.create = exports.list = void 0;
exports.getRolesForUser = getRolesForUser;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var PERMISSION_KEYS = [
"manage_channels",
"manage_roles",
"manage_messages",
"create_invite",
"embed_links",
"attach_files",
"move_members",
"mute_members",
];
function getRolesForUser(ctx, userId) {
return __awaiter(this, void 0, void 0, function () {
var assignments, roles;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("userRoles")
.withIndex("by_user", function (q) { return q.eq("userId", userId); })
.collect()];
case 1:
assignments = _a.sent();
return [4 /*yield*/, Promise.all(assignments.map(function (ur) { return ctx.db.get(ur.roleId); }))];
case 2:
roles = _a.sent();
return [2 /*return*/, roles.filter(function (r) { return r !== null; })];
}
});
});
}
// List all roles
exports.list = (0, server_1.query)({
args: {},
returns: values_1.v.array(values_1.v.any()),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
var roles;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.query("roles").collect()];
case 1:
roles = _a.sent();
return [2 /*return*/, roles.sort(function (a, b) { return (b.position || 0) - (a.position || 0); })];
}
});
}); },
});
// Create new role
exports.create = (0, server_1.mutation)({
args: {
name: values_1.v.optional(values_1.v.string()),
color: values_1.v.optional(values_1.v.string()),
permissions: values_1.v.optional(values_1.v.any()),
position: values_1.v.optional(values_1.v.number()),
isHoist: values_1.v.optional(values_1.v.boolean()),
},
returns: values_1.v.any(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var id;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.insert("roles", {
name: args.name || "new role",
color: args.color || "#99aab5",
position: args.position || 0,
permissions: args.permissions || {},
isHoist: args.isHoist || false,
})];
case 1:
id = _a.sent();
return [4 /*yield*/, ctx.db.get(id)];
case 2: return [2 /*return*/, _a.sent()];
}
});
}); },
});
// Update role properties
exports.update = (0, server_1.mutation)({
args: {
id: values_1.v.id("roles"),
name: values_1.v.optional(values_1.v.string()),
color: values_1.v.optional(values_1.v.string()),
permissions: values_1.v.optional(values_1.v.any()),
position: values_1.v.optional(values_1.v.number()),
isHoist: values_1.v.optional(values_1.v.boolean()),
},
returns: values_1.v.any(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var role, id, fields, updates, _i, _a, _b, key, value;
return __generator(this, function (_c) {
switch (_c.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
role = _c.sent();
if (!role)
throw new Error("Role not found");
id = args.id, fields = __rest(args, ["id"]);
updates = {};
for (_i = 0, _a = Object.entries(fields); _i < _a.length; _i++) {
_b = _a[_i], key = _b[0], value = _b[1];
if (value !== undefined)
updates[key] = value;
}
if (!(Object.keys(updates).length > 0)) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.patch(id, updates)];
case 2:
_c.sent();
_c.label = 3;
case 3: return [4 /*yield*/, ctx.db.get(id)];
case 4: return [2 /*return*/, _c.sent()];
}
});
}); },
});
// Delete role
exports.remove = (0, server_1.mutation)({
args: { id: values_1.v.id("roles") },
returns: values_1.v.object({ success: values_1.v.boolean() }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var role, assignments, _i, assignments_1, a;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.get(args.id)];
case 1:
role = _a.sent();
if (!role)
throw new Error("Role not found");
return [4 /*yield*/, ctx.db
.query("userRoles")
.withIndex("by_role", function (q) { return q.eq("roleId", args.id); })
.collect()];
case 2:
assignments = _a.sent();
_i = 0, assignments_1 = assignments;
_a.label = 3;
case 3:
if (!(_i < assignments_1.length)) return [3 /*break*/, 6];
a = assignments_1[_i];
return [4 /*yield*/, ctx.db.delete(a._id)];
case 4:
_a.sent();
_a.label = 5;
case 5:
_i++;
return [3 /*break*/, 3];
case 6: return [4 /*yield*/, ctx.db.delete(args.id)];
case 7:
_a.sent();
return [2 /*return*/, { success: true }];
}
});
}); },
});
// List members with roles
exports.listMembers = (0, server_1.query)({
args: {},
returns: values_1.v.array(values_1.v.any()),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
var users;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.query("userProfiles").collect()];
case 1:
users = _a.sent();
return [4 /*yield*/, Promise.all(users.map(function (user) { return __awaiter(void 0, void 0, void 0, function () {
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_a = {
id: user._id,
username: user.username,
public_identity_key: user.publicIdentityKey
};
return [4 /*yield*/, getRolesForUser(ctx, user._id)];
case 1: return [2 /*return*/, (_a.roles = _b.sent(),
_a)];
}
});
}); }))];
case 2: return [2 /*return*/, _a.sent()];
}
});
}); },
});
// Assign role to user
exports.assign = (0, server_1.mutation)({
args: {
roleId: values_1.v.id("roles"),
userId: values_1.v.id("userProfiles"),
},
returns: values_1.v.object({ success: values_1.v.boolean() }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("userRoles")
.withIndex("by_user_and_role", function (q) {
return q.eq("userId", args.userId).eq("roleId", args.roleId);
})
.unique()];
case 1:
existing = _a.sent();
if (!!existing) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.insert("userRoles", {
userId: args.userId,
roleId: args.roleId,
})];
case 2:
_a.sent();
_a.label = 3;
case 3: return [2 /*return*/, { success: true }];
}
});
}); },
});
// Remove role from user
exports.unassign = (0, server_1.mutation)({
args: {
roleId: values_1.v.id("roles"),
userId: values_1.v.id("userProfiles"),
},
returns: values_1.v.object({ success: values_1.v.boolean() }),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("userRoles")
.withIndex("by_user_and_role", function (q) {
return q.eq("userId", args.userId).eq("roleId", args.roleId);
})
.unique()];
case 1:
existing = _a.sent();
if (!existing) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.delete(existing._id)];
case 2:
_a.sent();
_a.label = 3;
case 3: return [2 /*return*/, { success: true }];
}
});
}); },
});
// Get current user's aggregated permissions
exports.getMyPermissions = (0, server_1.query)({
args: { userId: values_1.v.id("userProfiles") },
returns: values_1.v.object({
manage_channels: values_1.v.boolean(),
manage_roles: values_1.v.boolean(),
manage_messages: values_1.v.boolean(),
create_invite: values_1.v.boolean(),
embed_links: values_1.v.boolean(),
attach_files: values_1.v.boolean(),
move_members: values_1.v.boolean(),
mute_members: values_1.v.boolean(),
}),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var roles, finalPerms, _loop_1, _i, PERMISSION_KEYS_1, key;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, getRolesForUser(ctx, args.userId)];
case 1:
roles = _a.sent();
finalPerms = {};
_loop_1 = function (key) {
finalPerms[key] = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a[key]; });
};
for (_i = 0, PERMISSION_KEYS_1 = PERMISSION_KEYS; _i < PERMISSION_KEYS_1.length; _i++) {
key = PERMISSION_KEYS_1[_i];
_loop_1(key);
}
return [2 /*return*/, finalPerms];
}
});
}); },
});

View File

@@ -1,123 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var server_1 = require("convex/server");
var values_1 = require("convex/values");
exports.default = (0, server_1.defineSchema)({
userProfiles: (0, server_1.defineTable)({
username: values_1.v.string(),
clientSalt: values_1.v.string(),
encryptedMasterKey: values_1.v.string(),
hashedAuthKey: values_1.v.string(),
publicIdentityKey: values_1.v.string(),
publicSigningKey: values_1.v.string(),
encryptedPrivateKeys: values_1.v.string(),
isAdmin: values_1.v.boolean(),
status: values_1.v.optional(values_1.v.string()),
displayName: values_1.v.optional(values_1.v.string()),
avatarStorageId: values_1.v.optional(values_1.v.id("_storage")),
aboutMe: values_1.v.optional(values_1.v.string()),
customStatus: values_1.v.optional(values_1.v.string()),
joinSoundStorageId: values_1.v.optional(values_1.v.id("_storage")),
}).index("by_username", ["username"]),
categories: (0, server_1.defineTable)({
name: values_1.v.string(),
position: values_1.v.number(),
}).index("by_position", ["position"]),
channels: (0, server_1.defineTable)({
name: values_1.v.string(),
type: values_1.v.string(), // 'text' | 'voice' | 'dm'
categoryId: values_1.v.optional(values_1.v.id("categories")),
topic: values_1.v.optional(values_1.v.string()),
position: values_1.v.optional(values_1.v.number()),
}).index("by_name", ["name"])
.index("by_category", ["categoryId"]),
messages: (0, server_1.defineTable)({
channelId: values_1.v.id("channels"),
senderId: values_1.v.id("userProfiles"),
ciphertext: values_1.v.string(),
nonce: values_1.v.string(),
signature: values_1.v.string(),
keyVersion: values_1.v.number(),
replyTo: values_1.v.optional(values_1.v.id("messages")),
editedAt: values_1.v.optional(values_1.v.number()),
pinned: values_1.v.optional(values_1.v.boolean()),
}).index("by_channel", ["channelId"])
.index("by_channel_pinned", ["channelId", "pinned"]),
messageReactions: (0, server_1.defineTable)({
messageId: values_1.v.id("messages"),
userId: values_1.v.id("userProfiles"),
emoji: values_1.v.string(),
})
.index("by_message", ["messageId"])
.index("by_message_user_emoji", ["messageId", "userId", "emoji"]),
channelKeys: (0, server_1.defineTable)({
channelId: values_1.v.id("channels"),
userId: values_1.v.id("userProfiles"),
encryptedKeyBundle: values_1.v.string(),
keyVersion: values_1.v.number(),
})
.index("by_channel", ["channelId"])
.index("by_user", ["userId"])
.index("by_channel_and_user", ["channelId", "userId"]),
roles: (0, server_1.defineTable)({
name: values_1.v.string(),
color: values_1.v.string(),
position: values_1.v.number(),
permissions: values_1.v.any(), // JSON object of permissions
isHoist: values_1.v.boolean(),
}),
userRoles: (0, server_1.defineTable)({
userId: values_1.v.id("userProfiles"),
roleId: values_1.v.id("roles"),
})
.index("by_user", ["userId"])
.index("by_role", ["roleId"])
.index("by_user_and_role", ["userId", "roleId"]),
invites: (0, server_1.defineTable)({
code: values_1.v.string(),
encryptedPayload: values_1.v.string(),
createdBy: values_1.v.id("userProfiles"),
maxUses: values_1.v.optional(values_1.v.number()),
uses: values_1.v.number(),
expiresAt: values_1.v.optional(values_1.v.number()), // timestamp
keyVersion: values_1.v.number(),
}).index("by_code", ["code"]),
dmParticipants: (0, server_1.defineTable)({
channelId: values_1.v.id("channels"),
userId: values_1.v.id("userProfiles"),
})
.index("by_channel", ["channelId"])
.index("by_user", ["userId"]),
typingIndicators: (0, server_1.defineTable)({
channelId: values_1.v.id("channels"),
userId: values_1.v.id("userProfiles"),
username: values_1.v.string(),
expiresAt: values_1.v.number(), // timestamp
}).index("by_channel", ["channelId"]),
voiceStates: (0, server_1.defineTable)({
channelId: values_1.v.id("channels"),
userId: values_1.v.id("userProfiles"),
username: values_1.v.string(),
isMuted: values_1.v.boolean(),
isDeafened: values_1.v.boolean(),
isScreenSharing: values_1.v.boolean(),
isServerMuted: values_1.v.boolean(),
watchingStream: values_1.v.optional(values_1.v.id("userProfiles")),
})
.index("by_channel", ["channelId"])
.index("by_user", ["userId"]),
channelReadState: (0, server_1.defineTable)({
userId: values_1.v.id("userProfiles"),
channelId: values_1.v.id("channels"),
lastReadTimestamp: values_1.v.number(),
})
.index("by_user", ["userId"])
.index("by_channel", ["channelId"])
.index("by_user_and_channel", ["userId", "channelId"]),
serverSettings: (0, server_1.defineTable)({
serverName: values_1.v.optional(values_1.v.string()),
afkChannelId: values_1.v.optional(values_1.v.id("channels")),
afkTimeout: values_1.v.number(), // seconds (default 300 = 5 min)
iconStorageId: values_1.v.optional(values_1.v.id("_storage")),
}),
});

View File

@@ -1,230 +0,0 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.clearAfkChannel = exports.updateIcon = exports.updateName = exports.update = exports.get = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var roles_1 = require("./roles");
exports.get = (0, server_1.query)({
args: {},
returns: values_1.v.any(),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
var settings, iconUrl;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.query("serverSettings").first()];
case 1:
settings = _a.sent();
if (!settings)
return [2 /*return*/, null];
iconUrl = null;
if (!settings.iconStorageId) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.storage.getUrl(settings.iconStorageId)];
case 2:
iconUrl = _a.sent();
_a.label = 3;
case 3: return [2 /*return*/, __assign(__assign({}, settings), { iconUrl: iconUrl })];
}
});
}); },
});
exports.update = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
afkChannelId: values_1.v.optional(values_1.v.id("channels")),
afkTimeout: values_1.v.number(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var roles, canManage, channel, existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.userId)];
case 1:
roles = _a.sent();
canManage = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["manage_channels"]; });
if (!canManage) {
throw new Error("You don't have permission to manage server settings");
}
// Validate timeout range
if (args.afkTimeout < 60 || args.afkTimeout > 3600) {
throw new Error("AFK timeout must be between 60 and 3600 seconds");
}
if (!args.afkChannelId) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.get(args.afkChannelId)];
case 2:
channel = _a.sent();
if (!channel)
throw new Error("AFK channel not found");
if (channel.type !== "voice")
throw new Error("AFK channel must be a voice channel");
_a.label = 3;
case 3: return [4 /*yield*/, ctx.db.query("serverSettings").first()];
case 4:
existing = _a.sent();
if (!existing) return [3 /*break*/, 6];
return [4 /*yield*/, ctx.db.patch(existing._id, {
afkChannelId: args.afkChannelId,
afkTimeout: args.afkTimeout,
})];
case 5:
_a.sent();
return [3 /*break*/, 8];
case 6: return [4 /*yield*/, ctx.db.insert("serverSettings", {
afkChannelId: args.afkChannelId,
afkTimeout: args.afkTimeout,
})];
case 7:
_a.sent();
_a.label = 8;
case 8: return [2 /*return*/, null];
}
});
}); },
});
exports.updateName = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
serverName: values_1.v.string(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var roles, canManage, name, existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.userId)];
case 1:
roles = _a.sent();
canManage = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["manage_channels"]; });
if (!canManage) {
throw new Error("You don't have permission to manage server settings");
}
name = args.serverName.trim();
if (name.length === 0 || name.length > 100) {
throw new Error("Server name must be between 1 and 100 characters");
}
return [4 /*yield*/, ctx.db.query("serverSettings").first()];
case 2:
existing = _a.sent();
if (!existing) return [3 /*break*/, 4];
return [4 /*yield*/, ctx.db.patch(existing._id, { serverName: name })];
case 3:
_a.sent();
return [3 /*break*/, 6];
case 4: return [4 /*yield*/, ctx.db.insert("serverSettings", {
serverName: name,
afkTimeout: 300,
})];
case 5:
_a.sent();
_a.label = 6;
case 6: return [2 /*return*/, null];
}
});
}); },
});
exports.updateIcon = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
iconStorageId: values_1.v.optional(values_1.v.id("_storage")),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var roles, canManage, existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.userId)];
case 1:
roles = _a.sent();
canManage = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["manage_channels"]; });
if (!canManage) {
throw new Error("You don't have permission to manage server settings");
}
return [4 /*yield*/, ctx.db.query("serverSettings").first()];
case 2:
existing = _a.sent();
if (!existing) return [3 /*break*/, 4];
return [4 /*yield*/, ctx.db.patch(existing._id, {
iconStorageId: args.iconStorageId,
})];
case 3:
_a.sent();
return [3 /*break*/, 6];
case 4: return [4 /*yield*/, ctx.db.insert("serverSettings", {
iconStorageId: args.iconStorageId,
afkTimeout: 300,
})];
case 5:
_a.sent();
_a.label = 6;
case 6: return [2 /*return*/, null];
}
});
}); },
});
exports.clearAfkChannel = (0, server_1.internalMutation)({
args: { channelId: values_1.v.id("channels") },
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var settings;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.query("serverSettings").first()];
case 1:
settings = _a.sent();
if (!(settings && settings.afkChannelId === args.channelId)) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.patch(settings._id, { afkChannelId: undefined })];
case 2:
_a.sent();
_a.label = 3;
case 3: return [2 /*return*/, null];
}
});
}); },
});

View File

@@ -1,72 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.rewriteToPublicUrl = rewriteToPublicUrl;
exports.getPublicStorageUrl = getPublicStorageUrl;
// Change this if your public IP changes
var PUBLIC_CONVEX_URL = "http://72.26.56.3:3210";
/** Rewrite any URL to use the public hostname/port/protocol */
function rewriteToPublicUrl(url) {
try {
var original = new URL(url);
var target = new URL(PUBLIC_CONVEX_URL);
original.hostname = target.hostname;
original.port = target.port;
original.protocol = target.protocol;
return original.toString();
}
catch (_a) {
return url;
}
}
/** Get a storage file URL rewritten to the public address */
function getPublicStorageUrl(ctx, storageId) {
return __awaiter(this, void 0, void 0, function () {
var url;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.storage.getUrl(storageId)];
case 1:
url = _a.sent();
if (!url)
return [2 /*return*/, null];
return [2 /*return*/, rewriteToPublicUrl(url)];
}
});
});
}

View File

@@ -2,9 +2,11 @@
"compilerOptions": {
"allowJs": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true
"skipLibCheck": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]

View File

@@ -1,167 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.cleanExpired = exports.getTyping = exports.stopTyping = exports.startTyping = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var api_1 = require("./_generated/api");
var TYPING_TTL_MS = 6000;
exports.startTyping = (0, server_1.mutation)({
args: {
channelId: values_1.v.id("channels"),
userId: values_1.v.id("userProfiles"),
username: values_1.v.string(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var expiresAt, existing, userTyping;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
expiresAt = Date.now() + TYPING_TTL_MS;
return [4 /*yield*/, ctx.db
.query("typingIndicators")
.withIndex("by_channel", function (q) { return q.eq("channelId", args.channelId); })
.collect()];
case 1:
existing = _a.sent();
userTyping = existing.find(function (t) { return t.userId === args.userId; });
if (!userTyping) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.patch(userTyping._id, { expiresAt: expiresAt })];
case 2:
_a.sent();
return [3 /*break*/, 5];
case 3: return [4 /*yield*/, ctx.db.insert("typingIndicators", {
channelId: args.channelId,
userId: args.userId,
username: args.username,
expiresAt: expiresAt,
})];
case 4:
_a.sent();
_a.label = 5;
case 5: return [4 /*yield*/, ctx.scheduler.runAfter(TYPING_TTL_MS, api_1.internal.typing.cleanExpired, {})];
case 6:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
exports.stopTyping = (0, server_1.mutation)({
args: {
channelId: values_1.v.id("channels"),
userId: values_1.v.id("userProfiles"),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var indicators, mine;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("typingIndicators")
.withIndex("by_channel", function (q) { return q.eq("channelId", args.channelId); })
.collect()];
case 1:
indicators = _a.sent();
mine = indicators.find(function (t) { return t.userId === args.userId; });
if (!mine) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.delete(mine._id)];
case 2:
_a.sent();
_a.label = 3;
case 3: return [2 /*return*/, null];
}
});
}); },
});
exports.getTyping = (0, server_1.query)({
args: { channelId: values_1.v.id("channels") },
returns: values_1.v.array(values_1.v.object({
userId: values_1.v.id("userProfiles"),
username: values_1.v.string(),
})),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var now, indicators;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
now = Date.now();
return [4 /*yield*/, ctx.db
.query("typingIndicators")
.withIndex("by_channel", function (q) { return q.eq("channelId", args.channelId); })
.collect()];
case 1:
indicators = _a.sent();
return [2 /*return*/, indicators
.filter(function (t) { return t.expiresAt > now; })
.map(function (t) { return ({ userId: t.userId, username: t.username }); })];
}
});
}); },
});
exports.cleanExpired = (0, server_1.internalMutation)({
args: {},
returns: values_1.v.null(),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
var now, expired, _i, expired_1, t;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
now = Date.now();
return [4 /*yield*/, ctx.db.query("typingIndicators").collect()];
case 1:
expired = _a.sent();
_i = 0, expired_1 = expired;
_a.label = 2;
case 2:
if (!(_i < expired_1.length)) return [3 /*break*/, 5];
t = expired_1[_i];
if (!(t.expiresAt <= now)) return [3 /*break*/, 4];
return [4 /*yield*/, ctx.db.delete(t._id)];
case 3:
_a.sent();
_a.label = 4;
case 4:
_i++;
return [3 /*break*/, 2];
case 5: return [2 /*return*/, null];
}
});
}); },
});

View File

@@ -1,76 +0,0 @@
"use strict";
"use node";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getToken = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var livekit_server_sdk_1 = require("livekit-server-sdk");
// Generate LiveKit token for voice channel
exports.getToken = (0, server_1.action)({
args: {
channelId: values_1.v.string(),
userId: values_1.v.string(),
username: values_1.v.string(),
},
returns: values_1.v.object({ token: values_1.v.string() }),
handler: function (_ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var apiKey, apiSecret, at, token;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
apiKey = process.env.LIVEKIT_API_KEY || "devkey";
apiSecret = process.env.LIVEKIT_API_SECRET || "secret";
at = new livekit_server_sdk_1.AccessToken(apiKey, apiSecret, {
identity: args.userId,
name: args.username,
});
at.addGrant({
roomJoin: true,
room: args.channelId,
canPublish: true,
canSubscribe: true,
});
return [4 /*yield*/, at.toJwt()];
case 1:
token = _a.sent();
return [2 /*return*/, { token: token }];
}
});
}); },
});

View File

@@ -1,456 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.moveUser = exports.disconnectUser = exports.afkMove = exports.getAll = exports.setWatchingStream = exports.serverMute = exports.updateState = exports.leave = exports.join = void 0;
var server_1 = require("./_generated/server");
var values_1 = require("convex/values");
var storageUrl_1 = require("./storageUrl");
var roles_1 = require("./roles");
function removeUserVoiceStates(ctx, userId) {
return __awaiter(this, void 0, void 0, function () {
var existing, _i, existing_1, vs;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("voiceStates")
.withIndex("by_user", function (q) { return q.eq("userId", userId); })
.collect()];
case 1:
existing = _a.sent();
_i = 0, existing_1 = existing;
_a.label = 2;
case 2:
if (!(_i < existing_1.length)) return [3 /*break*/, 5];
vs = existing_1[_i];
return [4 /*yield*/, ctx.db.delete(vs._id)];
case 3:
_a.sent();
_a.label = 4;
case 4:
_i++;
return [3 /*break*/, 2];
case 5: return [2 /*return*/];
}
});
});
}
exports.join = (0, server_1.mutation)({
args: {
channelId: values_1.v.id("channels"),
userId: values_1.v.id("userProfiles"),
username: values_1.v.string(),
isMuted: values_1.v.boolean(),
isDeafened: values_1.v.boolean(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, removeUserVoiceStates(ctx, args.userId)];
case 1:
_a.sent();
return [4 /*yield*/, ctx.db.insert("voiceStates", {
channelId: args.channelId,
userId: args.userId,
username: args.username,
isMuted: args.isMuted,
isDeafened: args.isDeafened,
isScreenSharing: false,
isServerMuted: false,
})];
case 2:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
exports.leave = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, removeUserVoiceStates(ctx, args.userId)];
case 1:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
exports.updateState = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
isMuted: values_1.v.optional(values_1.v.boolean()),
isDeafened: values_1.v.optional(values_1.v.boolean()),
isScreenSharing: values_1.v.optional(values_1.v.boolean()),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing, _1, updates, filtered, allStates, _i, allStates_1, s;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db
.query("voiceStates")
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
.first()];
case 1:
existing = _a.sent();
if (!existing) return [3 /*break*/, 7];
_1 = args.userId, updates = __rest(args, ["userId"]);
filtered = Object.fromEntries(Object.entries(updates).filter(function (_a) {
var val = _a[1];
return val !== undefined;
}));
return [4 /*yield*/, ctx.db.patch(existing._id, filtered)];
case 2:
_a.sent();
if (!(args.isScreenSharing === false)) return [3 /*break*/, 7];
return [4 /*yield*/, ctx.db.query("voiceStates").collect()];
case 3:
allStates = _a.sent();
_i = 0, allStates_1 = allStates;
_a.label = 4;
case 4:
if (!(_i < allStates_1.length)) return [3 /*break*/, 7];
s = allStates_1[_i];
if (!(s.watchingStream === args.userId)) return [3 /*break*/, 6];
return [4 /*yield*/, ctx.db.patch(s._id, { watchingStream: undefined })];
case 5:
_a.sent();
_a.label = 6;
case 6:
_i++;
return [3 /*break*/, 4];
case 7: return [2 /*return*/, null];
}
});
}); },
});
exports.serverMute = (0, server_1.mutation)({
args: {
actorUserId: values_1.v.id("userProfiles"),
targetUserId: values_1.v.id("userProfiles"),
isServerMuted: values_1.v.boolean(),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var roles, canMute, existing;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.actorUserId)];
case 1:
roles = _a.sent();
canMute = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["mute_members"]; });
if (!canMute) {
throw new Error("You don't have permission to server mute members");
}
return [4 /*yield*/, ctx.db
.query("voiceStates")
.withIndex("by_user", function (q) { return q.eq("userId", args.targetUserId); })
.first()];
case 2:
existing = _a.sent();
if (!existing)
throw new Error("Target user is not in a voice channel");
return [4 /*yield*/, ctx.db.patch(existing._id, { isServerMuted: args.isServerMuted })];
case 3:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
exports.setWatchingStream = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
watchingStream: values_1.v.optional(values_1.v.id("userProfiles")),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var existing;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0: return [4 /*yield*/, ctx.db
.query("voiceStates")
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
.first()];
case 1:
existing = _b.sent();
if (!existing) return [3 /*break*/, 3];
return [4 /*yield*/, ctx.db.patch(existing._id, {
watchingStream: (_a = args.watchingStream) !== null && _a !== void 0 ? _a : undefined,
})];
case 2:
_b.sent();
_b.label = 3;
case 3: return [2 /*return*/, null];
}
});
}); },
});
exports.getAll = (0, server_1.query)({
args: {},
returns: values_1.v.any(),
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
var states, grouped, _i, states_1, s, user, avatarUrl, joinSoundUrl;
var _a, _b;
var _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0: return [4 /*yield*/, ctx.db.query("voiceStates").collect()];
case 1:
states = _d.sent();
grouped = {};
_i = 0, states_1 = states;
_d.label = 2;
case 2:
if (!(_i < states_1.length)) return [3 /*break*/, 9];
s = states_1[_i];
return [4 /*yield*/, ctx.db.get(s.userId)];
case 3:
user = _d.sent();
avatarUrl = null;
if (!(user === null || user === void 0 ? void 0 : user.avatarStorageId)) return [3 /*break*/, 5];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, user.avatarStorageId)];
case 4:
avatarUrl = _d.sent();
_d.label = 5;
case 5:
joinSoundUrl = null;
if (!(user === null || user === void 0 ? void 0 : user.joinSoundStorageId)) return [3 /*break*/, 7];
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, user.joinSoundStorageId)];
case 6:
joinSoundUrl = _d.sent();
_d.label = 7;
case 7:
((_a = grouped[_c = s.channelId]) !== null && _a !== void 0 ? _a : (grouped[_c] = [])).push({
userId: s.userId,
username: s.username,
isMuted: s.isMuted,
isDeafened: s.isDeafened,
isScreenSharing: s.isScreenSharing,
isServerMuted: s.isServerMuted,
avatarUrl: avatarUrl,
joinSoundUrl: joinSoundUrl,
watchingStream: (_b = s.watchingStream) !== null && _b !== void 0 ? _b : null,
});
_d.label = 8;
case 8:
_i++;
return [3 /*break*/, 2];
case 9: return [2 /*return*/, grouped];
}
});
}); },
});
exports.afkMove = (0, server_1.mutation)({
args: {
userId: values_1.v.id("userProfiles"),
afkChannelId: values_1.v.id("channels"),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var settings, currentState, allStates, _i, allStates_2, s;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, ctx.db.query("serverSettings").first()];
case 1:
settings = _a.sent();
if (!settings || settings.afkChannelId !== args.afkChannelId) {
throw new Error("Invalid AFK channel");
}
return [4 /*yield*/, ctx.db
.query("voiceStates")
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
.first()];
case 2:
currentState = _a.sent();
// No-op if not in voice or already in AFK channel
if (!currentState || currentState.channelId === args.afkChannelId)
return [2 /*return*/, null];
// Move to AFK channel: delete old state, insert new one muted
return [4 /*yield*/, ctx.db.delete(currentState._id)];
case 3:
// Move to AFK channel: delete old state, insert new one muted
_a.sent();
return [4 /*yield*/, ctx.db.insert("voiceStates", {
channelId: args.afkChannelId,
userId: args.userId,
username: currentState.username,
isMuted: true,
isDeafened: currentState.isDeafened,
isScreenSharing: false,
isServerMuted: currentState.isServerMuted,
})];
case 4:
_a.sent();
return [4 /*yield*/, ctx.db.query("voiceStates").collect()];
case 5:
allStates = _a.sent();
_i = 0, allStates_2 = allStates;
_a.label = 6;
case 6:
if (!(_i < allStates_2.length)) return [3 /*break*/, 9];
s = allStates_2[_i];
if (!(s.watchingStream === args.userId)) return [3 /*break*/, 8];
return [4 /*yield*/, ctx.db.patch(s._id, { watchingStream: undefined })];
case 7:
_a.sent();
_a.label = 8;
case 8:
_i++;
return [3 /*break*/, 6];
case 9: return [2 /*return*/, null];
}
});
}); },
});
exports.disconnectUser = (0, server_1.mutation)({
args: {
actorUserId: values_1.v.id("userProfiles"),
targetUserId: values_1.v.id("userProfiles"),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var roles, canMove, allStates, _i, allStates_3, s;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.actorUserId)];
case 1:
roles = _a.sent();
canMove = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["move_members"]; });
if (!canMove) {
throw new Error("You don't have permission to disconnect members");
}
return [4 /*yield*/, ctx.db.query("voiceStates").collect()];
case 2:
allStates = _a.sent();
_i = 0, allStates_3 = allStates;
_a.label = 3;
case 3:
if (!(_i < allStates_3.length)) return [3 /*break*/, 6];
s = allStates_3[_i];
if (!(s.watchingStream === args.targetUserId)) return [3 /*break*/, 5];
return [4 /*yield*/, ctx.db.patch(s._id, { watchingStream: undefined })];
case 4:
_a.sent();
_a.label = 5;
case 5:
_i++;
return [3 /*break*/, 3];
case 6: return [4 /*yield*/, removeUserVoiceStates(ctx, args.targetUserId)];
case 7:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});
exports.moveUser = (0, server_1.mutation)({
args: {
actorUserId: values_1.v.id("userProfiles"),
targetUserId: values_1.v.id("userProfiles"),
targetChannelId: values_1.v.id("channels"),
},
returns: values_1.v.null(),
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
var roles, canMove, targetChannel, currentState;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.actorUserId)];
case 1:
roles = _a.sent();
canMove = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["move_members"]; });
if (!canMove) {
throw new Error("You don't have permission to move members");
}
return [4 /*yield*/, ctx.db.get(args.targetChannelId)];
case 2:
targetChannel = _a.sent();
if (!targetChannel)
throw new Error("Target channel not found");
if (targetChannel.type !== "voice")
throw new Error("Target channel is not a voice channel");
return [4 /*yield*/, ctx.db
.query("voiceStates")
.withIndex("by_user", function (q) { return q.eq("userId", args.targetUserId); })
.first()];
case 3:
currentState = _a.sent();
if (!currentState)
throw new Error("Target user is not in a voice channel");
// No-op if already in the target channel
if (currentState.channelId === args.targetChannelId)
return [2 /*return*/, null];
// Delete old voice state and insert new one preserving mute/deaf/screenshare
return [4 /*yield*/, ctx.db.delete(currentState._id)];
case 4:
// Delete old voice state and insert new one preserving mute/deaf/screenshare
_a.sent();
return [4 /*yield*/, ctx.db.insert("voiceStates", {
channelId: args.targetChannelId,
userId: args.targetUserId,
username: currentState.username,
isMuted: currentState.isMuted,
isDeafened: currentState.isDeafened,
isScreenSharing: currentState.isScreenSharing,
isServerMuted: currentState.isServerMuted,
})];
case 5:
_a.sent();
return [2 /*return*/, null];
}
});
}); },
});

13
package-lock.json generated
View File

@@ -29,7 +29,7 @@
},
"apps/electron": {
"name": "@discord-clone/electron",
"version": "1.0.14",
"version": "1.0.17",
"dependencies": {
"@discord-clone/shared": "*",
"electron-log": "^5.4.3",
@@ -8889,6 +8889,12 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/sql.js": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.0.tgz",
"integrity": "sha512-NXYh+kFqLiYRCNAaHD0PcbjFgXyjuolEKLMk5vRt2DgPENtF1kkNzzMlg42dUk5wIsH8MhUzsRhaUxIisoSlZQ==",
"license": "MIT"
},
"node_modules/ssri": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz",
@@ -9864,7 +9870,7 @@
},
"packages/shared": {
"name": "@discord-clone/shared",
"version": "1.0.14",
"version": "1.0.17",
"dependencies": {
"@convex-dev/presence": "^0.3.0",
"@dnd-kit/core": "^6.3.1",
@@ -9880,7 +9886,8 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"sql.js": "^1.12.0"
}
}
}

View File

@@ -6,6 +6,10 @@ import crypto from './crypto.js';
import session from './session.js';
import settings from './settings.js';
import idle from './idle.js';
import searchStorage from './searchStorage.js';
import SearchDatabase from '@discord-clone/shared/src/utils/SearchDatabase';
const searchDB = new SearchDatabase(searchStorage, crypto);
const webPlatform = {
crypto,
@@ -31,10 +35,12 @@ const webPlatform = {
},
windowControls: null,
updates: null,
searchDB,
features: {
hasWindowControls: false,
hasScreenCapture: true,
hasNativeUpdates: false,
hasSearch: true,
},
};

View File

@@ -0,0 +1,54 @@
const DB_NAME = 'discord-clone-search';
const STORE_NAME = 'databases';
const DB_VERSION = 1;
function openIDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
const searchStorage = {
async load(userId) {
const db = await openIDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get(`search-db-${userId}`);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
},
async save(userId, bytes) {
const db = await openIDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.put(bytes, `search-db-${userId}`);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
async clear(userId) {
const db = await openIDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.delete(`search-db-${userId}`);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
},
};
export default searchStorage;

View File

@@ -19,6 +19,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"sql.js": "^1.12.0"
}
}

View File

@@ -4,6 +4,7 @@ import Login from './pages/Login';
import Register from './pages/Register';
import Chat from './pages/Chat';
import { usePlatform } from './platform';
import { useSearch } from './contexts/SearchContext';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@@ -12,6 +13,7 @@ function AuthGuard({ children }) {
const location = useLocation();
const navigate = useNavigate();
const { session, settings } = usePlatform();
const searchCtx = useSearch();
useEffect(() => {
let cancelled = false;
@@ -19,6 +21,7 @@ function AuthGuard({ children }) {
async function restoreSession() {
// Already have keys in sessionStorage — current session is active
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
searchCtx?.initialize();
if (!cancelled) setAuthState('authenticated');
return;
}
@@ -34,6 +37,8 @@ function AuthGuard({ children }) {
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
sessionStorage.setItem('signingKey', savedSession.signingKey);
sessionStorage.setItem('privateKey', savedSession.privateKey);
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
searchCtx?.initialize();
// Restore user preferences from file-based backup into localStorage
if (settings) {
try {

View File

@@ -27,6 +27,7 @@ import MessageItem, { getUserColor } from './MessageItem';
import ColoredIcon from './ColoredIcon';
import { usePlatform } from '../platform';
import { useVoice } from '../contexts/VoiceContext';
import { useSearch } from '../contexts/SearchContext';
const metadataCache = new Map();
const attachmentCache = new Map();
@@ -433,7 +434,7 @@ const EmojiButton = ({ onClick, active }) => {
const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner, canDelete }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); window.addEventListener('close-context-menus', h); return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; }, [onClose]);
React.useLayoutEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
@@ -468,7 +469,7 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner, canDelete }) =
const InputContextMenu = ({ x, y, onClose, onPaste }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); window.addEventListener('close-context-menus', h); return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; }, [onClose]);
React.useLayoutEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
@@ -492,6 +493,7 @@ const InputContextMenu = ({ x, y, onClose, onPaste }) => {
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => {
const { crypto } = usePlatform();
const { isReceivingScreenShareAudio } = useVoice();
const searchCtx = useSearch();
const [decryptedMessages, setDecryptedMessages] = useState([]);
const [input, setInput] = useState('');
const [zoomedImage, setZoomedImage] = useState(null);
@@ -704,6 +706,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
evictCacheIfNeeded();
// Index successfully decrypted messages for search
if (searchCtx?.isReady) {
const toIndex = needsDecryption.map(msg => {
const cached = messageDecryptionCache.get(msg.id);
if (!cached || cached.content.startsWith('[')) return null;
return {
id: msg.id,
channel_id: channelId,
sender_id: msg.sender_id,
username: msg.username,
content: cached.content,
created_at: msg.created_at,
pinned: msg.pinned,
replyToId: msg.replyToId,
};
}).filter(Boolean);
if (toIndex.length > 0) searchCtx.indexMessages(toIndex);
}
if (cancelled) return;
// Phase 3: Re-render with newly decrypted content
@@ -714,6 +735,24 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return () => { cancelled = true; };
}, [rawMessages, channelKey]);
// Index cached messages when search DB becomes ready (covers messages decrypted before DB init)
useEffect(() => {
if (!searchCtx?.isReady || !channelId || decryptedMessages.length === 0) return;
const toIndex = decryptedMessages
.filter(m => m.content && !m.content.startsWith('['))
.map(m => ({
id: m.id,
channel_id: channelId,
sender_id: m.sender_id,
username: m.username,
content: m.content,
created_at: m.created_at,
pinned: m.pinned,
replyToId: m.replyToId,
}));
if (toIndex.length > 0) searchCtx.indexMessages(toIndex);
}, [searchCtx?.isReady]);
useEffect(() => {
// Don't clear messageDecryptionCache — it persists across channel switches
setDecryptedMessages([]);
@@ -725,7 +764,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
setMentionQuery(null);
setUnreadDividerTimestamp(null);
onTogglePinned();
}, [channelId, channelKey]);
}, [channelId]);
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
@@ -1341,7 +1380,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
username={username}
onHover={() => setHoveredMessageId(msg.id)}
onLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
@@ -1440,6 +1479,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onKeyUp={saveSelection}
onContextMenu={(e) => {
e.preventDefault();
window.dispatchEvent(new Event('close-context-menus'));
setInputContextMenu({ x: e.clientX, y: e.clientY });
}}
onPaste={(e) => {

View File

@@ -1,12 +1,40 @@
import React, { useState } from 'react';
import React from 'react';
import Tooltip from './Tooltip';
const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, showMembers, onTogglePinned, serverName, isMobile, onMobileBack }) => {
const [searchFocused, setSearchFocused] = useState(false);
const ChatHeader = ({
channelName,
channelType,
channelTopic,
onToggleMembers,
showMembers,
onTogglePinned,
serverName,
isMobile,
onMobileBack,
// Search props
searchQuery,
onSearchQueryChange,
onSearchSubmit,
onSearchFocus,
onSearchBlur,
searchInputRef,
searchActive,
}) => {
const isDM = channelType === 'dm';
const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`;
const handleSearchKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
onSearchSubmit?.();
}
if (e.key === 'Escape') {
e.preventDefault();
onSearchBlur?.();
e.target.blur();
}
};
return (
<div className="chat-header">
<div className="chat-header-left">
@@ -65,14 +93,29 @@ const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, s
</Tooltip>
)}
{!isMobile && (
<div className="chat-header-search-wrapper">
<div className="chat-header-search-wrapper" ref={searchInputRef}>
<svg className="chat-header-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
</svg>
<input
type="text"
placeholder={searchPlaceholder}
className={`chat-header-search ${searchFocused ? 'focused' : ''}`}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
className={`chat-header-search ${searchActive ? 'focused' : ''}`}
value={searchQuery || ''}
onChange={(e) => onSearchQueryChange?.(e.target.value)}
onFocus={onSearchFocus}
onKeyDown={handleSearchKeyDown}
/>
{searchQuery && (
<button
className="chat-header-search-clear"
onClick={() => onSearchQueryChange?.('')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z"/>
</svg>
</button>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,244 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { detectActivePrefix } from '../utils/searchUtils';
const FILTER_SUGGESTIONS = [
{ prefix: 'from:', label: 'from:', description: 'user', icon: 'user' },
{ prefix: 'mentions:', label: 'mentions:', description: 'user', icon: 'at' },
{ prefix: 'has:', label: 'has:', description: 'link, file, image, or video', icon: 'has' },
{ prefix: 'in:', label: 'in:', description: 'channel', icon: 'channel' },
{ prefix: 'before:', label: 'before:', description: 'date', icon: 'date' },
{ prefix: 'after:', label: 'after:', description: 'date', icon: 'date' },
{ prefix: 'pinned:', label: 'pinned:', description: 'true or false', icon: 'pin' },
];
const HAS_OPTIONS = [
{ value: 'link', label: 'link' },
{ value: 'file', label: 'file' },
{ value: 'image', label: 'image' },
{ value: 'video', label: 'video' },
];
function getAvatarColor(name) {
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
let hash = 0;
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
function FilterIcon({ type }) {
switch (type) {
case 'user':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
);
case 'at':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10h5v-2h-5c-4.34 0-8-3.66-8-8s3.66-8 8-8 8 3.66 8 8v1.43c0 .79-.71 1.57-1.5 1.57s-1.5-.78-1.5-1.57V12c0-2.76-2.24-5-5-5s-5 2.24-5 5 2.24 5 5 5c1.38 0 2.64-.56 3.54-1.47.65.89 1.77 1.47 2.96 1.47 1.97 0 3.5-1.6 3.5-3.57V12c0-5.52-4.48-10-10-10zm0 13c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>
</svg>
);
case 'has':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
</svg>
);
case 'channel':
return <span style={{ fontSize: 16, fontWeight: 700, opacity: 0.7 }}>#</span>;
case 'date':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/>
</svg>
);
case 'pin':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.3 5.3a1 1 0 00-1.4-1.4L14.6 7.2l-1.5-.8a2 2 0 00-2.2.2L8.5 9a1 1 0 000 1.5l1.8 1.8-4.6 4.6a1 1 0 001.4 1.4l4.6-4.6 1.8 1.8a1 1 0 001.5 0l2.4-2.4a2 2 0 00.2-2.2l-.8-1.5 3.3-3.3z"/>
</svg>
);
default:
return null;
}
}
const SearchDropdown = ({
visible,
searchText,
channels,
members,
searchHistory,
onSelectFilter,
onSelectHistoryItem,
onClearHistory,
onClearHistoryItem,
anchorRef,
onClose,
}) => {
const dropdownRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0, width: 420 });
// Position dropdown below anchor
useEffect(() => {
if (!visible || !anchorRef?.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({
top: rect.bottom + 4,
left: Math.max(rect.right - 420, 8),
width: 420,
});
}, [visible, anchorRef, searchText]);
// Click outside to close
useEffect(() => {
if (!visible) return;
const handler = (e) => {
if (
dropdownRef.current && !dropdownRef.current.contains(e.target) &&
anchorRef?.current && !anchorRef.current.contains(e.target)
) {
onClose();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [visible, onClose, anchorRef]);
if (!visible) return null;
const activePrefix = detectActivePrefix(searchText);
let content;
if (activePrefix?.prefix === 'from' || activePrefix?.prefix === 'mentions') {
const filtered = (members || []).filter(m =>
m.username.toLowerCase().includes(activePrefix.partial)
);
const headerText = activePrefix.prefix === 'from' ? 'FROM USER' : 'MENTIONS USER';
content = (
<div className="search-dropdown-scrollable">
<div className="search-dropdown-section-header">{headerText}</div>
{filtered.length === 0 && (
<div className="search-dropdown-empty">No matching users</div>
)}
{filtered.map(m => (
<div
key={m.id}
className="search-dropdown-member"
onClick={() => onSelectFilter(activePrefix.prefix, m.username)}
>
{m.avatarUrl ? (
<img src={m.avatarUrl} className="search-dropdown-avatar" alt="" />
) : (
<div className="search-dropdown-avatar" style={{ backgroundColor: getAvatarColor(m.username) }}>
{m.username[0]?.toUpperCase()}
</div>
)}
<span className="search-dropdown-member-name">{m.username}</span>
</div>
))}
</div>
);
} else if (activePrefix?.prefix === 'in') {
const filtered = (channels || []).filter(c =>
c.name?.toLowerCase().includes(activePrefix.partial) && c.type === 'text'
);
content = (
<div className="search-dropdown-scrollable">
<div className="search-dropdown-section-header">IN CHANNEL</div>
{filtered.length === 0 && (
<div className="search-dropdown-empty">No matching channels</div>
)}
{filtered.map(c => (
<div
key={c._id}
className="search-dropdown-channel"
onClick={() => onSelectFilter('in', c.name)}
>
<span className="search-dropdown-channel-hash">#</span>
<span>{c.name}</span>
</div>
))}
</div>
);
} else if (activePrefix?.prefix === 'has') {
const filtered = HAS_OPTIONS.filter(o =>
o.value.includes(activePrefix.partial)
);
content = (
<div className="search-dropdown-scrollable">
<div className="search-dropdown-section-header">MESSAGE CONTAINS</div>
{filtered.map(o => (
<div
key={o.value}
className="search-dropdown-item"
onClick={() => onSelectFilter('has', o.value)}
>
<FilterIcon type="has" />
<span>{o.label}</span>
</div>
))}
</div>
);
} else {
// Default: show filter suggestions + search history
content = (
<div className="search-dropdown-scrollable">
<div className="search-dropdown-section-header">SEARCH OPTIONS</div>
{FILTER_SUGGESTIONS.map(f => (
<div
key={f.prefix}
className="search-dropdown-item"
onClick={() => onSelectFilter(f.prefix.replace(':', ''), null)}
>
<span className="search-dropdown-item-icon"><FilterIcon type={f.icon} /></span>
<span className="search-dropdown-item-label">{f.label}</span>
<span className="search-dropdown-item-desc">{f.description}</span>
</div>
))}
{searchHistory && searchHistory.length > 0 && (
<>
<div className="search-dropdown-section-header search-dropdown-history-header">
<span>SEARCH HISTORY</span>
<button className="search-dropdown-clear-all" onClick={onClearHistory}>Clear</button>
</div>
{searchHistory.map((item, i) => (
<div
key={i}
className="search-dropdown-history-item"
onClick={() => onSelectHistoryItem(item)}
>
<svg className="search-dropdown-history-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3a9 9 0 00-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0013 21a9 9 0 000-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/>
</svg>
<span className="search-dropdown-history-text">{item}</span>
<button
className="search-dropdown-history-delete"
onClick={(e) => { e.stopPropagation(); onClearHistoryItem(i); }}
>
&times;
</button>
</div>
))}
</>
)}
</div>
);
}
return ReactDOM.createPortal(
<div
ref={dropdownRef}
className="search-dropdown"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{content}
</div>,
document.body
);
};
export default SearchDropdown;

View File

@@ -0,0 +1,408 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useSearch } from '../contexts/SearchContext';
import { parseFilters } from '../utils/searchUtils';
import { usePlatform } from '../platform';
function formatTime(ts) {
const d = new Date(ts);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getAvatarColor(name) {
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
let hash = 0;
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
const CONVEX_PUBLIC_URL = 'http://72.26.56.3:3210';
const rewriteStorageUrl = (url) => {
try {
const u = new URL(url);
const pub = new URL(CONVEX_PUBLIC_URL);
u.hostname = pub.hostname;
u.port = pub.port;
u.protocol = pub.protocol;
return u.toString();
} catch { return url; }
};
const toHexString = (bytes) =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
const searchImageCache = new Map();
const SearchResultImage = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
const [error, setError] = useState(null);
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search image decrypt error:', err);
if (isMounted) { setError('Failed to load'); setLoading(false); }
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
if (error) return null;
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
};
const SearchResultVideo = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
const [error, setError] = useState(null);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search video decrypt error:', err);
if (isMounted) { setError('Failed to load'); setLoading(false); }
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
if (error) return null;
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
return (
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}></div>}
</div>
);
};
const SearchResultFile = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) return;
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search file decrypt error:', err);
if (isMounted) setLoading(false);
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
return (
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
<span style={{ marginRight: 8, fontSize: 20 }}>📄</span>
<div style={{ overflow: 'hidden', flex: 1 }}>
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
</div>
</div>
);
};
const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => {
const { search, isReady } = useSearch() || {};
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const [showSortMenu, setShowSortMenu] = useState(false);
// Execute search when query changes
useEffect(() => {
if (!visible || !query?.trim() || !search || !isReady) {
if (!query?.trim()) setResults([]);
return;
}
setSearching(true);
const { textQuery, filters } = parseFilters(query);
let channelId;
if (isDM) {
// In DM view — always scope to the DM channel
channelId = dmChannelId;
} else {
channelId = filters.channelName
? channels?.find(c => c.name?.toLowerCase() === filters.channelName.toLowerCase())?._id
: undefined;
}
const params = {
query: textQuery || undefined,
channelId,
senderName: filters.senderName,
hasLink: filters.hasLink,
hasImage: filters.hasImage,
hasVideo: filters.hasVideo,
hasFile: filters.hasFile,
hasMention: filters.hasMention,
before: filters.before,
after: filters.after,
pinned: filters.pinned,
limit: 25,
};
const res = search(params);
let filtered;
if (isDM) {
// In DM view — results are already scoped to dmChannelId
filtered = res;
} else {
// In server view — filter out DM messages
const serverChannelIds = new Set(channels?.map(c => c._id) || []);
filtered = res.filter(r => serverChannelIds.has(r.channel_id));
}
// Sort results
let sorted = [...filtered];
if (sortOrder === 'oldest') {
sorted.sort((a, b) => a.created_at - b.created_at);
} else {
// newest first (default)
sorted.sort((a, b) => b.created_at - a.created_at);
}
setResults(sorted);
setSearching(false);
}, [visible, query, sortOrder, search, isReady, channels, isDM, dmChannelId]);
const handleResultClick = useCallback((result) => {
onJumpToMessage(result.channel_id, result.id);
}, [onJumpToMessage]);
if (!visible) return null;
const channelMap = {};
if (channels) {
for (const c of channels) channelMap[c._id] = c.name;
}
// Group results by channel
const grouped = {};
for (const r of results) {
const chName = channelMap[r.channel_id] || 'Unknown';
if (!grouped[chName]) grouped[chName] = [];
grouped[chName].push(r);
}
const { filters: activeFilters } = query?.trim() ? parseFilters(query) : { filters: {} };
const filterChips = [];
if (activeFilters.senderName) filterChips.push({ label: `from: ${activeFilters.senderName}`, key: 'from' });
if (activeFilters.hasLink) filterChips.push({ label: 'has: link', key: 'hasLink' });
if (activeFilters.hasImage) filterChips.push({ label: 'has: image', key: 'hasImage' });
if (activeFilters.hasVideo) filterChips.push({ label: 'has: video', key: 'hasVideo' });
if (activeFilters.hasFile) filterChips.push({ label: 'has: file', key: 'hasFile' });
if (activeFilters.hasMention) filterChips.push({ label: 'has: mention', key: 'hasMention' });
if (activeFilters.before) filterChips.push({ label: `before: ${activeFilters.before}`, key: 'before' });
if (activeFilters.after) filterChips.push({ label: `after: ${activeFilters.after}`, key: 'after' });
if (activeFilters.pinned) filterChips.push({ label: 'pinned: true', key: 'pinned' });
if (activeFilters.channelName) filterChips.push({ label: `in: ${activeFilters.channelName}`, key: 'in' });
const sortLabel = sortOrder === 'oldest' ? 'Oldest' : 'Newest';
return (
<div className="search-panel">
<div className="search-panel-header">
<div className="search-panel-header-left">
<span className="search-result-count">
{results.length} result{results.length !== 1 ? 's' : ''}
</span>
</div>
<div className="search-panel-header-right">
<div className="search-panel-sort-wrapper">
<button
className="search-panel-sort-btn"
onClick={() => setShowSortMenu(prev => !prev)}
>
{sortLabel}
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 4 }}>
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
{showSortMenu && (
<div className="search-panel-sort-menu">
<div
className={`search-panel-sort-option ${sortOrder === 'newest' ? 'active' : ''}`}
onClick={() => { onSortChange('newest'); setShowSortMenu(false); }}
>
Newest
</div>
<div
className={`search-panel-sort-option ${sortOrder === 'oldest' ? 'active' : ''}`}
onClick={() => { onSortChange('oldest'); setShowSortMenu(false); }}
>
Oldest
</div>
</div>
)}
</div>
<button className="search-panel-close" onClick={onClose}>&times;</button>
</div>
</div>
{filterChips.length > 0 && (
<div className="search-filter-chips">
{filterChips.map(chip => (
<span key={chip.key} className="search-filter-chip">
{chip.label}
</span>
))}
</div>
)}
<div className="search-panel-results">
{!isReady && (
<div className="search-panel-empty">Search database is loading...</div>
)}
{isReady && searching && <div className="search-panel-empty">Searching...</div>}
{isReady && !searching && results.length === 0 && (
<div className="search-panel-empty">
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.3, marginBottom: 8 }}>
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
</svg>
<div>No results found</div>
</div>
)}
{Object.entries(grouped).map(([chName, msgs]) => (
<div key={chName}>
<div className="search-channel-header">{isDM ? chName : `#${chName}`}</div>
{msgs.map(r => (
<div
key={r.id}
className="search-result"
onClick={() => handleResultClick(r)}
>
<div
className="search-result-avatar"
style={{ backgroundColor: getAvatarColor(r.username) }}
>
{r.username?.[0]?.toUpperCase()}
</div>
<div className="search-result-body">
<div className="search-result-header">
<span className="search-result-username">{r.username}</span>
<span className="search-result-time">{formatTime(r.created_at)}</span>
</div>
{!(r.has_attachment && r.attachment_meta) && (
<div
className="search-result-content"
dangerouslySetInnerHTML={{ __html: r.snippet || escapeHtml(r.content) }}
/>
)}
{r.has_attachment && r.attachment_meta ? (() => {
try {
const meta = JSON.parse(r.attachment_meta);
if (r.attachment_type?.startsWith('image/')) return <SearchResultImage metadata={meta} />;
if (r.attachment_type?.startsWith('video/')) return <SearchResultVideo metadata={meta} />;
return <SearchResultFile metadata={meta} />;
} catch { return <span className="search-result-badge">File</span>; }
})() : r.has_attachment ? <span className="search-result-badge">File</span> : null}
{r.has_link && <span className="search-result-badge">Link</span>}
{r.pinned && <span className="search-result-badge">Pinned</span>}
</div>
</div>
))}
</div>
))}
</div>
</div>
);
};
export default SearchPanel;

View File

@@ -93,7 +93,7 @@ const STATUS_OPTIONS = [
];
const UserControlPanel = React.memo(({ username, userId }) => {
const { session, idle } = usePlatform();
const { session, idle, searchDB } = usePlatform();
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [showUserSettings, setShowUserSettings] = useState(false);
@@ -137,6 +137,10 @@ const UserControlPanel = React.memo(({ username, userId }) => {
if (connectionState === 'connected') {
try { disconnectVoice(); } catch {}
}
// Save and close search DB
if (searchDB?.isOpen()) {
try { await searchDB.save(); searchDB.close(); } catch {}
}
// Clear persisted session
if (session) {
try { await session.clear(); } catch {}
@@ -383,7 +387,8 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
useEffect(() => {
const h = () => onClose();
window.addEventListener('click', h);
return () => window.removeEventListener('click', h);
window.addEventListener('close-context-menus', h);
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); };
}, [onClose]);
useLayoutEffect(() => {
@@ -489,7 +494,8 @@ const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCatego
useEffect(() => {
const h = () => onClose();
window.addEventListener('click', h);
return () => window.removeEventListener('click', h);
window.addEventListener('close-context-menus', h);
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); };
}, [onClose]);
useLayoutEffect(() => {
@@ -1062,6 +1068,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
window.dispatchEvent(new Event('close-context-menus'));
setVoiceUserMenu({ x: e.clientX, y: e.clientY, user });
}}
>
@@ -1332,6 +1339,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={(e) => {
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
e.preventDefault();
window.dispatchEvent(new Event('close-context-menus'));
setChannelListContextMenu({ x: e.clientX, y: e.clientY });
}
}}>

View File

@@ -5,6 +5,8 @@ import Avatar from './Avatar';
import AvatarCropModal from './AvatarCropModal';
import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
import { useVoice } from '../contexts/VoiceContext';
import { useSearch } from '../contexts/SearchContext';
import { usePlatform } from '../platform';
const THEME_PREVIEWS = {
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
@@ -18,6 +20,7 @@ const TABS = [
{ id: 'appearance', label: 'Appearance', section: 'USER SETTINGS' },
{ id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' },
{ id: 'keybinds', label: 'Keybinds', section: 'APP SETTINGS' },
{ id: 'search', label: 'Search', section: 'APP SETTINGS' },
];
const UserSettings = ({ onClose, userId, username, onLogout }) => {
@@ -111,6 +114,7 @@ const UserSettings = ({ onClose, userId, username, onLogout }) => {
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'voice' && <VoiceVideoTab />}
{activeTab === 'keybinds' && <KeybindsTab />}
{activeTab === 'search' && <SearchTab userId={userId} />}
</div>
{/* Right spacer with close button */}
@@ -845,4 +849,259 @@ const KeybindsTab = () => {
);
};
/* =========================================
SEARCH TAB
========================================= */
const TAG_HEX_LEN = 32;
const SearchTab = ({ userId }) => {
const convex = useConvex();
const { crypto } = usePlatform();
const searchCtx = useSearch();
const [status, setStatus] = useState('idle'); // idle | rebuilding | done | error
const [progress, setProgress] = useState({ currentChannel: '', channelIndex: 0, totalChannels: 0, messagesIndexed: 0 });
const [errorMsg, setErrorMsg] = useState('');
const cancelledRef = useRef(false);
const handleRebuild = async () => {
if (!userId || !crypto || !searchCtx?.isReady) return;
cancelledRef.current = false;
setStatus('rebuilding');
setProgress({ currentChannel: '', channelIndex: 0, totalChannels: 0, messagesIndexed: 0 });
setErrorMsg('');
try {
// 1. Gather channels + DMs
const [channels, dmChannels, rawKeys] = await Promise.all([
convex.query(api.channels.list, {}),
convex.query(api.dms.listDMs, { userId }),
convex.query(api.channelKeys.getKeysForUser, { userId }),
]);
// 2. Decrypt channel keys
const privateKey = sessionStorage.getItem('privateKey');
if (!privateKey) throw new Error('Private key not found in session. Please re-login.');
const decryptedKeys = {};
for (const item of rawKeys) {
try {
const bundleJson = await crypto.privateDecrypt(privateKey, item.encrypted_key_bundle);
Object.assign(decryptedKeys, JSON.parse(bundleJson));
} catch (e) {
// Skip channels we can't decrypt
}
}
// 3. Build channel list: text channels + DMs that have keys
const textChannels = channels
.filter(c => c.type === 'text' && decryptedKeys[c._id])
.map(c => ({ id: c._id, name: '#' + c.name, key: decryptedKeys[c._id] }));
const dmItems = (dmChannels || [])
.filter(dm => decryptedKeys[dm.channel_id])
.map(dm => ({ id: dm.channel_id, name: '@' + dm.other_username, key: decryptedKeys[dm.channel_id] }));
const allChannels = [...textChannels, ...dmItems];
if (allChannels.length === 0) {
setStatus('done');
setProgress(p => ({ ...p, totalChannels: 0 }));
return;
}
setProgress(p => ({ ...p, totalChannels: allChannels.length }));
let totalIndexed = 0;
// 4. For each channel, paginate and decrypt
for (let i = 0; i < allChannels.length; i++) {
if (cancelledRef.current) break;
const ch = allChannels[i];
setProgress(p => ({ ...p, currentChannel: ch.name, channelIndex: i + 1 }));
let cursor = null;
let isDone = false;
while (!isDone) {
if (cancelledRef.current) break;
const paginationOpts = { numItems: 100, cursor };
const result = await convex.query(api.messages.fetchBulkPage, {
channelId: ch.id,
paginationOpts,
});
if (result.page.length > 0) {
// Build decrypt batch
const decryptItems = [];
const msgMap = [];
for (const msg of result.page) {
if (msg.ciphertext && msg.ciphertext.length >= TAG_HEX_LEN) {
const tag = msg.ciphertext.slice(-TAG_HEX_LEN);
const content = msg.ciphertext.slice(0, -TAG_HEX_LEN);
decryptItems.push({ ciphertext: content, key: ch.key, iv: msg.nonce, tag });
msgMap.push(msg);
}
}
if (decryptItems.length > 0) {
const decryptResults = await crypto.decryptBatch(decryptItems);
const indexItems = [];
for (let j = 0; j < decryptResults.length; j++) {
const plaintext = decryptResults[j];
if (plaintext && plaintext !== '[Decryption Error]') {
indexItems.push({
id: msgMap[j].id,
channel_id: msgMap[j].channel_id,
sender_id: msgMap[j].sender_id,
username: msgMap[j].username,
content: plaintext,
created_at: msgMap[j].created_at,
pinned: msgMap[j].pinned,
replyToId: msgMap[j].replyToId,
});
}
}
if (indexItems.length > 0) {
searchCtx.indexMessages(indexItems);
totalIndexed += indexItems.length;
setProgress(p => ({ ...p, messagesIndexed: totalIndexed }));
}
}
}
isDone = result.isDone;
cursor = result.continueCursor;
// Yield to UI between pages
await new Promise(r => setTimeout(r, 10));
}
}
// 5. Save
await searchCtx.save();
setStatus(cancelledRef.current ? 'idle' : 'done');
setProgress(p => ({ ...p, messagesIndexed: totalIndexed }));
} catch (err) {
console.error('Search index rebuild failed:', err);
setErrorMsg(err.message || 'Unknown error');
setStatus('error');
}
};
const handleCancel = () => {
cancelledRef.current = true;
};
const formatNumber = (n) => n.toLocaleString();
return (
<div>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Search</h2>
<div style={{ backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', padding: '20px' }}>
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 8px', fontSize: '16px', fontWeight: '600' }}>
Search Index
</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: '0 0 16px', lineHeight: '1.4' }}>
Rebuild your local search index by downloading and decrypting all messages from the server. This may take a while for large servers.
</p>
{status === 'idle' && (
<button
onClick={handleRebuild}
disabled={!searchCtx?.isReady}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '10px 20px', cursor: searchCtx?.isReady ? 'pointer' : 'not-allowed',
fontSize: '14px', fontWeight: '500', opacity: searchCtx?.isReady ? 1 : 0.5,
}}
>
Rebuild Search Index
</button>
)}
{status === 'rebuilding' && (
<div>
{/* Progress bar */}
<div style={{
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', height: '8px',
overflow: 'hidden', marginBottom: '12px',
}}>
<div style={{
height: '100%', borderRadius: '4px',
backgroundColor: 'var(--brand-experiment)',
width: progress.totalChannels > 0
? `${(progress.channelIndex / progress.totalChannels) * 100}%`
: '0%',
transition: 'width 0.3s ease',
}} />
</div>
<div style={{ color: 'var(--text-normal)', fontSize: '14px', marginBottom: '4px' }}>
Indexing {progress.currentChannel}... ({progress.channelIndex} of {progress.totalChannels} channels)
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '13px', marginBottom: '12px' }}>
{formatNumber(progress.messagesIndexed)} messages indexed
</div>
<button
onClick={handleCancel}
style={{
backgroundColor: 'transparent', color: '#ed4245', border: '1px solid #ed4245',
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Cancel
</button>
</div>
)}
{status === 'done' && (
<div>
<div style={{ color: '#3ba55c', fontSize: '14px', marginBottom: '12px', fontWeight: '500' }}>
Complete! {formatNumber(progress.messagesIndexed)} messages indexed across {progress.totalChannels} channels.
</div>
<button
onClick={() => setStatus('idle')}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Rebuild Again
</button>
</div>
)}
{status === 'error' && (
<div>
<div style={{ color: '#ed4245', fontSize: '14px', marginBottom: '12px' }}>
Error: {errorMsg}
</div>
<button
onClick={() => setStatus('idle')}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Retry
</button>
</div>
)}
</div>
</div>
);
};
export default UserSettings;

View File

@@ -0,0 +1,75 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { usePlatform } from '../platform';
const SearchContext = createContext(null);
export function SearchProvider({ children }) {
const { searchDB, features } = usePlatform();
const [isReady, setIsReady] = useState(false);
const [initSignal, setInitSignal] = useState(0);
const initialize = useCallback(() => {
setInitSignal(s => s + 1);
}, []);
useEffect(() => {
if (!features?.hasSearch || !searchDB) return;
// Already open from a previous run
if (searchDB.isOpen()) {
setIsReady(true);
return;
}
const dbKey = sessionStorage.getItem('searchDbKey');
const userId = localStorage.getItem('userId');
if (!dbKey || !userId) return;
searchDB.open(dbKey, userId)
.then(() => {
setIsReady(true);
console.log('Search DB initialized');
})
.catch(err => {
console.error('Search DB init failed:', err);
});
const handleBeforeUnload = () => {
if (searchDB.isOpen()) {
searchDB.save();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [searchDB, features?.hasSearch, initSignal]);
const indexMessages = useCallback((messages) => {
if (!searchDB?.isOpen() || !messages?.length) return;
searchDB.indexMessages(messages);
}, [searchDB]);
const search = useCallback((params) => {
if (!searchDB?.isOpen()) return [];
return searchDB.search(params);
}, [searchDB]);
const save = useCallback(async () => {
if (searchDB?.isOpen()) {
await searchDB.save();
}
}, [searchDB]);
const value = useMemo(() => (
{ isReady, indexMessages, search, save, searchDB, initialize }
), [isReady, indexMessages, search, save, searchDB, initialize]);
return (
<SearchContext.Provider value={value}>
{children}
</SearchContext.Provider>
);
}
export function useSearch() {
return useContext(SearchContext);
}

View File

@@ -236,6 +236,7 @@ body {
display: flex;
flex: 1;
min-height: 0;
position: relative;
}
.chat-area {
@@ -889,25 +890,60 @@ body {
.chat-header-search-wrapper {
margin-left: 4px;
position: relative;
display: flex;
align-items: center;
}
.chat-header-search-icon {
position: absolute;
left: 8px;
color: var(--text-muted);
pointer-events: none;
z-index: 1;
display: flex;
align-items: center;
}
.chat-header-search {
width: 160px;
width: 214px;
height: 28px;
background-color: var(--bg-tertiary);
border: none;
background-color: #17171a;
border: 1px solid color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.2) 100%,hsl(0 0% 0% /0.2) 0%);
border-radius: 4px;
color: var(--text-normal);
padding: 0 8px;
color: color-mix(in oklab, hsl(240 calc(1*6.667%) 94.118% /1) 100%, #000 0%);
padding: 0 28px 0 28px;
font-size: 13px;
outline: none;
transition: width 0.25s ease;
font-family: inherit;
}
.chat-header-search::placeholder {
color: var(--text-muted);
}
.chat-header-search.focused {
width: 240px;
}
.chat-header-search-clear {
position: absolute;
right: 4px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
border-radius: 2px;
}
.chat-header-search-clear:hover {
color: var(--header-primary);
}
/* ============================================
MEMBERS LIST
============================================ */
@@ -3191,4 +3227,468 @@ body {
.is-mobile .friends-view {
width: 100vw;
}
/* Search panel full-width on mobile */
.is-mobile .search-panel {
width: 100vw;
right: 0;
border-radius: 0;
}
}
/* ============================================
SEARCH DROPDOWN (appears below header input)
============================================ */
.search-dropdown {
position: fixed;
z-index: 10001;
background-color: #111214;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
animation: searchDropdownIn 0.15s ease;
}
@keyframes searchDropdownIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.search-dropdown-scrollable {
max-height: 500px;
overflow-y: auto;
padding: 8px 0;
}
.search-dropdown-scrollable::-webkit-scrollbar {
width: 6px;
}
.search-dropdown-scrollable::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-auto-thumb, var(--bg-tertiary));
border-radius: 3px;
}
.search-dropdown-section-header {
font-size: 12px;
font-weight: 700;
color: var(--header-secondary);
text-transform: uppercase;
padding: 8px 16px 4px;
letter-spacing: 0.02em;
display: flex;
align-items: center;
justify-content: space-between;
}
.search-dropdown-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
cursor: pointer;
color: var(--text-normal);
font-size: 14px;
transition: background-color 0.1s;
}
.search-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-dropdown-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
color: var(--text-muted);
flex-shrink: 0;
}
.search-dropdown-item-label {
font-weight: 600;
color: var(--header-primary);
}
.search-dropdown-item-desc {
color: var(--text-muted);
font-size: 13px;
}
.search-dropdown-member {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.1s;
}
.search-dropdown-member:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-dropdown-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 11px;
flex-shrink: 0;
object-fit: cover;
}
img.search-dropdown-avatar {
object-fit: cover;
}
.search-dropdown-member-name {
color: var(--text-normal);
font-size: 14px;
font-weight: 500;
}
.search-dropdown-channel {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
color: var(--text-normal);
font-size: 14px;
transition: background-color 0.1s;
}
.search-dropdown-channel:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-dropdown-channel-hash {
font-size: 18px;
font-weight: 700;
color: var(--text-muted);
width: 24px;
text-align: center;
flex-shrink: 0;
}
.search-dropdown-history-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.search-dropdown-clear-all {
background: none;
border: none;
color: var(--text-link);
font-size: 12px;
cursor: pointer;
padding: 0;
font-weight: 500;
}
.search-dropdown-clear-all:hover {
text-decoration: underline;
}
.search-dropdown-history-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.1s;
}
.search-dropdown-history-item:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-dropdown-history-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.search-dropdown-history-text {
flex: 1;
color: var(--text-normal);
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-dropdown-history-delete {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
opacity: 0;
transition: opacity 0.1s;
line-height: 1;
}
.search-dropdown-history-item:hover .search-dropdown-history-delete {
opacity: 1;
}
.search-dropdown-history-delete:hover {
color: var(--header-primary);
}
.search-dropdown-empty {
padding: 12px 16px;
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
/* ============================================
SEARCH PANEL (results)
============================================ */
.search-panel {
position: absolute;
top: 0;
right: 0;
width: 420px;
height: 100%;
background-color: var(--bg-secondary);
border-left: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
z-index: 100;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3);
}
.search-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.search-panel-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.search-panel-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-result-count {
color: var(--header-secondary);
font-size: 13px;
font-weight: 600;
}
.search-panel-sort-wrapper {
position: relative;
}
.search-panel-sort-btn {
display: flex;
align-items: center;
background: none;
border: none;
color: var(--text-link);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-family: inherit;
}
.search-panel-sort-btn:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-panel-sort-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background-color: #111214;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 10;
overflow: hidden;
min-width: 120px;
}
.search-panel-sort-option {
padding: 8px 12px;
color: var(--text-normal);
font-size: 14px;
cursor: pointer;
transition: background-color 0.1s;
}
.search-panel-sort-option:hover {
background-color: rgba(255, 255, 255, 0.06);
}
.search-panel-sort-option.active {
color: var(--text-link);
}
.search-panel-close {
background: none;
border: none;
color: var(--header-secondary);
font-size: 20px;
cursor: pointer;
line-height: 1;
padding: 4px;
}
.search-panel-close:hover {
color: var(--header-primary);
}
.search-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 16px;
}
.search-filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
background-color: var(--brand-experiment);
color: white;
font-size: 12px;
font-weight: 500;
}
.search-panel-results {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
}
.search-panel-results::-webkit-scrollbar {
width: 6px;
}
.search-panel-results::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-auto-thumb, var(--bg-tertiary));
border-radius: 3px;
}
.search-panel-empty {
text-align: center;
color: var(--text-muted);
padding: 32px 16px;
display: flex;
flex-direction: column;
align-items: center;
}
.search-channel-header {
font-size: 12px;
font-weight: 600;
color: var(--header-secondary);
text-transform: uppercase;
padding: 8px 8px 4px;
letter-spacing: 0.02em;
}
.search-result {
background-color: var(--bg-primary);
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 4px;
cursor: pointer;
transition: background-color 0.1s;
display: flex;
gap: 12px;
}
.search-result:hover {
background-color: var(--bg-mod-faint);
}
.search-result-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 15px;
flex-shrink: 0;
}
.search-result-body {
flex: 1;
min-width: 0;
}
.search-result-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.search-result-username {
color: var(--header-primary);
font-size: 1rem;
font-weight: 600;
}
.search-result-time {
color: var(--text-muted);
font-size: 0.75rem;
margin-left: 0;
}
.search-result-content {
color: var(--text-normal);
font-size: 0.9375rem;
line-height: 1.375;
word-break: break-word;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.search-result-content mark {
background-color: rgba(250, 166, 26, 0.3);
color: var(--text-normal);
border-radius: 2px;
padding: 0 1px;
}
.search-result-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
background-color: var(--bg-tertiary);
padding: 1px 6px;
border-radius: 3px;
margin-top: 4px;
margin-right: 4px;
text-transform: uppercase;
}

View File

@@ -9,12 +9,16 @@ import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader';
import SearchPanel from '../components/SearchPanel';
import SearchDropdown from '../components/SearchDropdown';
import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import { usePlatform } from '../platform';
import { useIsMobile } from '../hooks/useIsMobile';
const MAX_SEARCH_HISTORY = 10;
const Chat = () => {
const { crypto, settings } = usePlatform();
const isMobile = useIsMobile();
@@ -31,6 +35,17 @@ const Chat = () => {
const [showPinned, setShowPinned] = useState(false);
const [mobileView, setMobileView] = useState('sidebar');
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [showSearchDropdown, setShowSearchDropdown] = useState(false);
const [showSearchResults, setShowSearchResults] = useState(false);
const [searchSortOrder, setSearchSortOrder] = useState('newest');
const [searchHistory, setSearchHistory] = useState(() => {
const id = localStorage.getItem('userId');
return id ? getUserPref(id, 'searchHistory', []) : [];
});
const searchInputRef = useRef(null);
const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
const prevDmChannelsRef = useRef(null);
@@ -41,7 +56,11 @@ const Chat = () => {
const handler = (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
// Quick switcher placeholder - could open a search modal
// Focus the search input
const input = searchInputRef.current?.querySelector('input');
if (input) {
input.focus();
}
}
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
@@ -57,6 +76,7 @@ const Chat = () => {
const serverSettings = useQuery(api.serverSettings.get);
const serverName = serverSettings?.serverName || 'Secure Chat';
const serverIconUrl = serverSettings?.iconUrl || null;
const allMembers = useQuery(api.members.listAll) || [];
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
@@ -195,6 +215,127 @@ const Chat = () => {
}
}, [voiceActiveChannelId]);
// Search handlers
const handleSearchQueryChange = useCallback((val) => {
setSearchQuery(val);
if (val === '') {
setShowSearchResults(false);
}
if (!showSearchDropdown && val !== undefined) {
setShowSearchDropdown(true);
}
}, [showSearchDropdown]);
const handleSearchFocus = useCallback(() => {
setShowSearchDropdown(true);
}, []);
const handleSearchBlur = useCallback(() => {
// Dropdown close is handled by click-outside in SearchDropdown
}, []);
const handleSearchSubmit = useCallback(() => {
if (!searchQuery.trim()) return;
setShowSearchDropdown(false);
setShowSearchResults(true);
// Save to history
setSearchHistory(prev => {
const filtered = prev.filter(h => h !== searchQuery.trim());
const updated = [searchQuery.trim(), ...filtered].slice(0, MAX_SEARCH_HISTORY);
if (userId) {
setUserPref(userId, 'searchHistory', updated, settings);
}
return updated;
});
}, [searchQuery, userId, settings]);
const handleSelectFilter = useCallback((prefix, value) => {
if (value !== null) {
// Replace the current active prefix with the completed token
const beforePrefix = searchQuery.replace(/\b(from|in|has|mentions):\S*$/i, '').trimEnd();
const newQuery = beforePrefix + (beforePrefix ? ' ' : '') + prefix + ':' + value + ' ';
setSearchQuery(newQuery);
} else {
// Just insert the prefix (e.g., clicking "from:" suggestion)
const newQuery = searchQuery + (searchQuery && !searchQuery.endsWith(' ') ? ' ' : '') + prefix + ':';
setSearchQuery(newQuery);
}
// Re-focus input
setTimeout(() => {
const input = searchInputRef.current?.querySelector('input');
if (input) input.focus();
}, 0);
}, [searchQuery]);
const handleSelectHistoryItem = useCallback((item) => {
setSearchQuery(item);
setShowSearchDropdown(false);
setShowSearchResults(true);
}, []);
const handleClearHistory = useCallback(() => {
setSearchHistory([]);
if (userId) {
setUserPref(userId, 'searchHistory', [], settings);
}
}, [userId, settings]);
const handleClearHistoryItem = useCallback((index) => {
setSearchHistory(prev => {
const updated = prev.filter((_, i) => i !== index);
if (userId) {
setUserPref(userId, 'searchHistory', updated, settings);
}
return updated;
});
}, [userId, settings]);
const handleCloseSearchDropdown = useCallback(() => {
setShowSearchDropdown(false);
}, []);
const handleCloseSearchResults = useCallback(() => {
setShowSearchResults(false);
setSearchQuery('');
}, []);
const handleJumpToMessage = useCallback((channelId, messageId) => {
// Switch to the correct channel if needed
const isDM = dmChannels.some(dm => dm.channel_id === channelId);
if (isDM) {
const dm = dmChannels.find(d => d.channel_id === channelId);
if (dm) {
setActiveDMChannel(dm);
setView('me');
}
} else {
setActiveChannel(channelId);
setView('server');
}
setShowSearchResults(false);
setSearchQuery('');
// Give time for channel to render then scroll
setTimeout(() => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
}, [dmChannels]);
// Shared search props for ChatHeader
const searchProps = {
searchQuery,
onSearchQueryChange: handleSearchQueryChange,
onSearchSubmit: handleSearchSubmit,
onSearchFocus: handleSearchFocus,
onSearchBlur: handleSearchBlur,
searchInputRef,
searchActive: showSearchDropdown || showSearchResults,
};
function renderMainContent() {
if (view === 'me') {
if (activeDMChannel) {
@@ -208,6 +349,7 @@ const Chat = () => {
onTogglePinned={() => setShowPinned(p => !p)}
isMobile={isMobile}
onMobileBack={handleMobileBack}
{...searchProps}
/>
<div className="chat-content">
<ChatArea
@@ -223,6 +365,17 @@ const Chat = () => {
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
isDM={true}
dmChannelId={activeDMChannel.channel_id}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
</div>
</div>
);
@@ -277,6 +430,7 @@ const Chat = () => {
serverName={serverName}
isMobile={isMobile}
onMobileBack={handleMobileBack}
{...searchProps}
/>
<div className="chat-content">
<ChatArea
@@ -297,6 +451,15 @@ const Chat = () => {
visible={effectiveShowMembers}
onMemberClick={(member) => {}}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
</div>
</div>
);
@@ -359,6 +522,21 @@ const Chat = () => {
)}
{showMainContent && renderMainContent()}
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
{showSearchDropdown && !isMobile && (
<SearchDropdown
visible={showSearchDropdown}
searchText={searchQuery}
channels={channels}
members={allMembers}
searchHistory={searchHistory}
onSelectFilter={handleSelectFilter}
onSelectHistoryItem={handleSelectHistoryItem}
onClearHistory={handleClearHistory}
onClearHistoryItem={handleClearHistoryItem}
anchorRef={searchInputRef}
onClose={handleCloseSearchDropdown}
/>
)}
<ToastContainer />
</div>
</PresenceProvider>

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useConvex } from 'convex/react';
import { usePlatform } from '../platform';
import { useSearch } from '../contexts/SearchContext';
import { api } from '../../../../convex/_generated/api';
const Login = () => {
@@ -12,6 +13,7 @@ const Login = () => {
const navigate = useNavigate();
const convex = useConvex();
const { crypto, session } = usePlatform();
const searchCtx = useSearch();
async function decryptEncryptedField(encryptedJson, keyHex) {
const obj = JSON.parse(encryptedJson);
@@ -32,6 +34,10 @@ const Login = () => {
const { dek, dak } = await crypto.deriveAuthKeys(password, salt);
console.log('Derived keys');
// Derive a separate key for the local search database
const searchKeys = await crypto.deriveAuthKeys(password, 'searchdb-' + username);
sessionStorage.setItem('searchDbKey', searchKeys.dak);
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
if (verifyData.error) {
@@ -74,6 +80,7 @@ const Login = () => {
publicKey: verifyData.publicKey || '',
signingKey,
privateKey: rsaPriv,
searchDbKey: searchKeys.dak,
savedAt: Date.now(),
});
} catch (e) {
@@ -83,6 +90,7 @@ const Login = () => {
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
searchCtx?.initialize();
navigate('/chat');
} catch (err) {
console.error('Login error:', err);

View File

@@ -57,11 +57,24 @@
* @property {() => Promise<object>} checkUpdate
*/
/**
* @typedef {Object} PlatformSearchDB
* @property {(dbKeyHex: string, userId: string) => Promise<void>} open
* @property {() => Promise<void>} close
* @property {() => Promise<void>} save
* @property {(messages: Array) => void} indexMessages
* @property {(params: object) => Array} search
* @property {(messageId: string) => boolean} isIndexed
* @property {() => boolean} isOpen
* @property {() => object} getStats
*/
/**
* @typedef {Object} PlatformFeatures
* @property {boolean} hasWindowControls
* @property {boolean} hasScreenCapture
* @property {boolean} hasNativeUpdates
* @property {boolean} hasSearch
*/
/**
@@ -74,6 +87,7 @@
* @property {PlatformScreenCapture|null} screenCapture
* @property {PlatformWindowControls|null} windowControls
* @property {PlatformUpdates|null} updates
* @property {PlatformSearchDB|null} searchDB
* @property {PlatformFeatures} features
*/

View File

@@ -0,0 +1,389 @@
import initSqlJsModule from 'sql.js';
const initSqlJs = initSqlJsModule.default || initSqlJsModule;
import wasmUrl from 'sql.js/dist/sql-wasm.wasm?url';
const URL_RE = /https?:\/\/[^\s<>]+/i;
const MENTION_RE = /@(\w+)/g;
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
sender_id TEXT,
username TEXT,
content TEXT,
created_at INTEGER,
has_attachment INTEGER DEFAULT 0,
has_link INTEGER DEFAULT 0,
has_mention INTEGER DEFAULT 0,
mentioned_users TEXT,
attachment_filename TEXT,
attachment_type TEXT,
attachment_meta TEXT DEFAULT '',
pinned INTEGER DEFAULT 0,
reply_to_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_channel ON messages(channel_id);
CREATE INDEX IF NOT EXISTS idx_sender ON messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_created ON messages(created_at);
`;
let sqlPromise = null;
function getSql() {
if (!sqlPromise) {
sqlPromise = initSqlJs({ locateFile: () => wasmUrl });
}
return sqlPromise;
}
export default class SearchDatabase {
constructor(storageAdapter, cryptoAdapter) {
this.storage = storageAdapter;
this.crypto = cryptoAdapter;
this.db = null;
this.dbKey = null;
this._dirty = false;
this._saveTimer = null;
this._opened = false;
this._userId = null;
}
isOpen() {
return this._opened && this.db !== null;
}
async open(dbKeyHex, userId) {
if (this._opened) return;
this.dbKey = dbKeyHex;
this._userId = userId;
const SQL = await getSql();
// Try loading from storage
let loaded = false;
try {
const blob = await this.storage.load(userId);
if (blob && blob.length > 0) {
const json = new TextDecoder().decode(blob);
const { content, iv, tag } = JSON.parse(json);
const decrypted = await this.crypto.decryptData(content, dbKeyHex, iv, tag);
const bytes = hexToBytes(decrypted);
this.db = new SQL.Database(bytes);
loaded = true;
// Drop old FTS5 artifacts from previous schema
try {
this.db.run('DROP TRIGGER IF EXISTS messages_ai');
this.db.run('DROP TRIGGER IF EXISTS messages_ad');
this.db.run('DROP TRIGGER IF EXISTS messages_au');
} catch {}
try { this.db.run('DROP TABLE IF EXISTS messages_fts'); } catch {}
// Migrate: add attachment_meta column if missing
try { this.db.run("ALTER TABLE messages ADD COLUMN attachment_meta TEXT DEFAULT ''"); } catch {}
console.log('Search DB loaded from encrypted storage');
}
} catch (err) {
console.warn('Search DB decrypt failed, starting fresh:', err.message);
}
if (!loaded) {
this.db = new SQL.Database();
this.db.run(SCHEMA_SQL);
console.log('Search DB created fresh');
}
this._opened = true;
this._dirty = false;
}
async close() {
if (!this._opened) return;
if (this._saveTimer) {
clearTimeout(this._saveTimer);
this._saveTimer = null;
}
if (this._dirty) {
await this.save();
}
if (this.db) {
this.db.close();
this.db = null;
}
this.dbKey = null;
this._opened = false;
this._userId = null;
}
async save() {
if (!this.db || !this.dbKey || !this._userId) return;
try {
const data = this.db.export();
const hex = bytesToHex(data);
const encrypted = await this.crypto.encryptData(hex, this.dbKey);
const json = JSON.stringify({ content: encrypted.content, iv: encrypted.iv, tag: encrypted.tag });
const bytes = new TextEncoder().encode(json);
await this.storage.save(this._userId, bytes);
this._dirty = false;
console.log('Search DB saved');
} catch (err) {
console.error('Search DB save error:', err);
}
}
_scheduleSave() {
if (this._saveTimer) return;
this._saveTimer = setTimeout(() => {
this._saveTimer = null;
this.save();
}, 30000);
}
indexMessages(messages) {
if (!this.db || messages.length === 0) return;
this.db.run('BEGIN TRANSACTION');
try {
const stmt = this.db.prepare(
`INSERT OR REPLACE INTO messages (id, channel_id, sender_id, username, content, created_at, has_attachment, has_link, has_mention, mentioned_users, attachment_filename, attachment_type, attachment_meta, pinned, reply_to_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const msg of messages) {
let content = msg.content || '';
let hasAttachment = 0;
let hasLink = 0;
let hasMention = 0;
let mentionedUsers = '';
let attachmentFilename = '';
let attachmentType = '';
let attachmentMeta = '';
// Parse attachment
try {
if (content.startsWith('{')) {
const parsed = JSON.parse(content);
if (parsed.type === 'attachment') {
hasAttachment = 1;
attachmentFilename = parsed.filename || '';
attachmentType = parsed.mimeType || '';
attachmentMeta = content;
content = `[File: ${attachmentFilename}]`;
}
}
} catch {}
// Check for links
if (URL_RE.test(content)) hasLink = 1;
// Check for mentions
const mentions = [];
let m;
while ((m = MENTION_RE.exec(content)) !== null) {
mentions.push(m[1]);
}
if (mentions.length > 0) {
hasMention = 1;
mentionedUsers = mentions.join(',');
}
const createdAt = typeof msg.created_at === 'number'
? msg.created_at
: new Date(msg.created_at).getTime();
stmt.run([
msg.id,
msg.channel_id,
msg.sender_id || null,
msg.username || null,
content,
createdAt,
hasAttachment,
hasLink,
hasMention,
mentionedUsers || null,
attachmentFilename || null,
attachmentType || null,
attachmentMeta || null,
msg.pinned ? 1 : 0,
msg.replyToId || null,
]);
}
stmt.free();
this.db.run('COMMIT');
this._dirty = true;
this._scheduleSave();
} catch (err) {
try { this.db.run('ROLLBACK'); } catch {}
console.error('Search DB indexing error:', err);
}
}
isIndexed(messageId) {
if (!this.db) return false;
try {
const stmt = this.db.prepare('SELECT 1 FROM messages WHERE id = ?');
stmt.bind([messageId]);
const found = stmt.step();
stmt.free();
return found;
} catch {
return false;
}
}
search({ query, channelId, senderId, senderName, hasLink, hasAttachment, hasImage, hasVideo, hasFile, hasMention, before, after, pinned, limit = 50, offset = 0 }) {
if (!this.db) return [];
try {
let sql, params = [];
const conditions = [];
let queryWords = [];
if (query && query.trim()) {
queryWords = query.trim().split(/\s+/).filter(Boolean);
sql = `SELECT m.* FROM messages m WHERE 1=1`;
for (const word of queryWords) {
conditions.push('(m.content LIKE ? OR m.username LIKE ? OR m.attachment_filename LIKE ?)');
const pattern = `%${word}%`;
params.push(pattern, pattern, pattern);
}
} else {
sql = `SELECT m.* FROM messages m WHERE 1=1`;
}
if (channelId) {
conditions.push('m.channel_id = ?');
params.push(channelId);
}
if (senderId) {
conditions.push('m.sender_id = ?');
params.push(senderId);
}
if (senderName) {
conditions.push('m.username = ?');
params.push(senderName);
}
if (hasLink) {
conditions.push('m.has_link = 1');
}
if (hasAttachment) {
conditions.push('m.has_attachment = 1');
}
if (hasImage) {
conditions.push("m.has_attachment = 1 AND m.attachment_type LIKE 'image/%'");
}
if (hasVideo) {
conditions.push("m.has_attachment = 1 AND m.attachment_type LIKE 'video/%'");
}
if (hasFile) {
conditions.push("m.has_attachment = 1 AND m.attachment_type NOT LIKE 'image/%' AND m.attachment_type NOT LIKE 'video/%'");
}
if (hasMention) {
conditions.push('m.has_mention = 1');
}
if (before) {
conditions.push('m.created_at < ?');
params.push(new Date(before).getTime());
}
if (after) {
conditions.push('m.created_at > ?');
params.push(new Date(after).getTime());
}
if (pinned) {
conditions.push('m.pinned = 1');
}
if (conditions.length > 0) {
sql += ' AND ' + conditions.join(' AND ');
}
sql += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = this.db.prepare(sql);
stmt.bind(params);
const results = [];
while (stmt.step()) {
const row = stmt.getAsObject();
results.push({
id: row.id,
channel_id: row.channel_id,
sender_id: row.sender_id,
username: row.username,
content: row.content,
created_at: row.created_at,
has_attachment: !!row.has_attachment,
has_link: !!row.has_link,
pinned: !!row.pinned,
attachment_type: row.attachment_type || '',
attachment_meta: row.attachment_meta || '',
snippet: queryWords.length > 0
? generateSnippet(row.content || '', queryWords)
: row.content,
reply_to_id: row.reply_to_id,
});
}
stmt.free();
return results;
} catch (err) {
console.error('Search DB query error:', err);
return [];
}
}
getStats() {
if (!this.db) return { count: 0 };
try {
const result = this.db.exec('SELECT COUNT(*) as cnt FROM messages');
return { count: result[0]?.values[0]?.[0] || 0 };
} catch {
return { count: 0 };
}
}
}
function generateSnippet(content, queryWords) {
if (!content) return '';
const lower = content.toLowerCase();
// Find earliest match position
let firstIdx = content.length;
for (const word of queryWords) {
const idx = lower.indexOf(word.toLowerCase());
if (idx !== -1 && idx < firstIdx) firstIdx = idx;
}
if (firstIdx === content.length) firstIdx = 0;
// Extract ~80 chars of context around the match
const start = Math.max(0, firstIdx - 40);
const end = Math.min(content.length, firstIdx + 40);
let slice = content.slice(start, end);
if (start > 0) slice = '...' + slice;
if (end < content.length) slice = slice + '...';
// Escape HTML then wrap matches in <mark>
slice = slice.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
for (const word of queryWords) {
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
slice = slice.replace(new RegExp(escaped, 'gi'), m => `<mark>${m}</mark>`);
}
return slice;
}
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
function bytesToHex(bytes) {
let hex = '';
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}

View File

@@ -0,0 +1,47 @@
const FILTER_RE = /\b(from|has|mentions|before|after|in|pinned):(\S+)/gi;
export function parseFilters(rawQuery) {
const filters = {};
let textQuery = rawQuery;
let match;
while ((match = FILTER_RE.exec(rawQuery)) !== null) {
const key = match[1].toLowerCase();
const val = match[2];
switch (key) {
case 'from': filters.senderName = val; break;
case 'has':
if (val === 'link') filters.hasLink = true;
else if (val === 'file' || val === 'attachment') filters.hasFile = true;
else if (val === 'image') filters.hasImage = true;
else if (val === 'video') filters.hasVideo = true;
else if (val === 'mention') filters.hasMention = true;
break;
case 'mentions': filters.hasMention = true; filters.mentionName = val; break;
case 'before': filters.before = val; break;
case 'after': filters.after = val; break;
case 'in': filters.channelName = val; break;
case 'pinned': filters.pinned = val === 'true' || val === 'yes'; break;
}
textQuery = textQuery.replace(match[0], '');
}
FILTER_RE.lastIndex = 0;
return { textQuery: textQuery.trim(), filters };
}
/**
* Detects if the user is mid-typing a filter token.
* e.g. "hello from:par" → { prefix: 'from', partial: 'par' }
* e.g. "from:" → { prefix: 'from', partial: '' }
* Returns null if no active prefix is detected at the end of text.
*/
export function detectActivePrefix(text) {
if (!text) return null;
// Match a filter prefix at the end of the string, possibly with a partial value
const m = text.match(/\b(from|in|has|mentions):(\S*)$/i);
if (m) {
return { prefix: m[1].toLowerCase(), partial: m[2].toLowerCase() };
}
return null;
}