diff --git a/TODO.md b/TODO.md index 9348ae4..ebe4061 100644 --- a/TODO.md +++ b/TODO.md @@ -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);" - - - - - - - - - - - Fix green status not updating correctly - - - - - - - - - - - - # Future - - - - - - - - - - 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. - +- 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/ - +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. - - - - - - -- 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. \ No newline at end of file diff --git a/apps/electron/main.cjs b/apps/electron/main.cjs index 140113e..631acb1 100644 --- a/apps/electron/main.cjs +++ b/apps/electron/main.cjs @@ -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()); diff --git a/apps/electron/preload.cjs b/apps/electron/preload.cjs index 25035b1..c032573 100644 --- a/apps/electron/preload.cjs +++ b/apps/electron/preload.cjs @@ -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'), diff --git a/apps/electron/src/main.jsx b/apps/electron/src/main.jsx index ec81f4b..6b5e125 100644 --- a/apps/electron/src/main.jsx +++ b/apps/electron/src/main.jsx @@ -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( - - - - - - + + + + + + + + diff --git a/apps/electron/src/platform/index.js b/apps/electron/src/platform/index.js index 6dc11e5..888b7ab 100644 --- a/apps/electron/src/platform/index.js +++ b/apps/electron/src/platform/index.js @@ -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, }, }; diff --git a/apps/web/src/main.jsx b/apps/web/src/main.jsx index a8716ed..f4f5188 100644 --- a/apps/web/src/main.jsx +++ b/apps/web/src/main.jsx @@ -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( - - - - - + + + + + + + diff --git a/convex/auth.js b/convex/auth.js deleted file mode 100644 index 5d99e4a..0000000 --- a/convex/auth.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/auth.ts b/convex/auth.ts index a89016c..844de2e 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -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) { diff --git a/convex/categories.js b/convex/categories.js deleted file mode 100644 index 89be86e..0000000 --- a/convex/categories.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/channelKeys.js b/convex/channelKeys.js deleted file mode 100644 index 6eecd06..0000000 --- a/convex/channelKeys.js +++ /dev/null @@ -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, - }); })]; - } - }); - }); }, -}); diff --git a/convex/channels.js b/convex/channels.js deleted file mode 100644 index 3dcd7c3..0000000 --- a/convex/channels.js +++ /dev/null @@ -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 }]; - } - }); - }); }, -}); diff --git a/convex/convex.config.js b/convex/convex.config.js deleted file mode 100644 index 582edf3..0000000 --- a/convex/convex.config.js +++ /dev/null @@ -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; diff --git a/convex/dms.js b/convex/dms.js deleted file mode 100644 index 419abd7..0000000 --- a/convex/dms.js +++ /dev/null @@ -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; })]; - } - }); - }); }, -}); diff --git a/convex/files.js b/convex/files.js deleted file mode 100644 index 0363018..0000000 --- a/convex/files.js +++ /dev/null @@ -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()]; - } - }); - }); }, -}); diff --git a/convex/gifs.js b/convex/gifs.js deleted file mode 100644 index b463846..0000000 --- a/convex/gifs.js +++ /dev/null @@ -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: [] }]; - }); - }); }, -}); diff --git a/convex/invites.js b/convex/invites.js deleted file mode 100644 index ed79e50..0000000 --- a/convex/invites.js +++ /dev/null @@ -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 }]; - } - }); - }); }, -}); diff --git a/convex/members.js b/convex/members.js deleted file mode 100644 index 446281a..0000000 --- a/convex/members.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/members.ts b/convex/members.ts index dfc4578..77e6240 100644 --- a/convex/members.ts +++ b/convex/members.ts @@ -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; + }, +}); diff --git a/convex/messages.js b/convex/messages.js deleted file mode 100644 index 505627f..0000000 --- a/convex/messages.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/messages.ts b/convex/messages.ts index 45ef767..d13eb9e 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -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(), diff --git a/convex/presence.js b/convex/presence.js deleted file mode 100644 index 81ccbc5..0000000 --- a/convex/presence.js +++ /dev/null @@ -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()]; - } - }); - }); }, -}); diff --git a/convex/reactions.js b/convex/reactions.js deleted file mode 100644 index 5d8fbed..0000000 --- a/convex/reactions.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/readState.js b/convex/readState.js deleted file mode 100644 index e10ee74..0000000 --- a/convex/readState.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/roles.js b/convex/roles.js deleted file mode 100644 index 7c1022c..0000000 --- a/convex/roles.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/schema.js b/convex/schema.js deleted file mode 100644 index 8d68aeb..0000000 --- a/convex/schema.js +++ /dev/null @@ -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")), - }), -}); diff --git a/convex/serverSettings.js b/convex/serverSettings.js deleted file mode 100644 index 4c8fb60..0000000 --- a/convex/serverSettings.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/storageUrl.js b/convex/storageUrl.js deleted file mode 100644 index 1882ae3..0000000 --- a/convex/storageUrl.js +++ /dev/null @@ -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)]; - } - }); - }); -} diff --git a/convex/tsconfig.json b/convex/tsconfig.json index 1755162..680a257 100644 --- a/convex/tsconfig.json +++ b/convex/tsconfig.json @@ -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"] diff --git a/convex/typing.js b/convex/typing.js deleted file mode 100644 index cc51361..0000000 --- a/convex/typing.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/convex/voice.js b/convex/voice.js deleted file mode 100644 index 9e661ae..0000000 --- a/convex/voice.js +++ /dev/null @@ -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 }]; - } - }); - }); }, -}); diff --git a/convex/voiceState.js b/convex/voiceState.js deleted file mode 100644 index 33387f2..0000000 --- a/convex/voiceState.js +++ /dev/null @@ -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]; - } - }); - }); }, -}); diff --git a/package-lock.json b/package-lock.json index 35270d5..1a6241c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/packages/platform-web/src/index.js b/packages/platform-web/src/index.js index 4c263c3..8152220 100644 --- a/packages/platform-web/src/index.js +++ b/packages/platform-web/src/index.js @@ -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, }, }; diff --git a/packages/platform-web/src/searchStorage.js b/packages/platform-web/src/searchStorage.js new file mode 100644 index 0000000..d97190f --- /dev/null +++ b/packages/platform-web/src/searchStorage.js @@ -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; diff --git a/packages/shared/package.json b/packages/shared/package.json index fdbe4ef..022c74c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" } } diff --git a/packages/shared/src/App.jsx b/packages/shared/src/App.jsx index 5c748d0..6251fc7 100644 --- a/packages/shared/src/App.jsx +++ b/packages/shared/src/App.jsx @@ -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 { diff --git a/packages/shared/src/components/ChatArea.jsx b/packages/shared/src/components/ChatArea.jsx index 0370fc8..86b498f 100644 --- a/packages/shared/src/components/ChatArea.jsx +++ b/packages/shared/src/components/ChatArea.jsx @@ -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) => { diff --git a/packages/shared/src/components/ChatHeader.jsx b/packages/shared/src/components/ChatHeader.jsx index ae5edbf..d026eb7 100644 --- a/packages/shared/src/components/ChatHeader.jsx +++ b/packages/shared/src/components/ChatHeader.jsx @@ -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 (
@@ -65,14 +93,29 @@ const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, s )} {!isMobile && ( -
+
+ + + setSearchFocused(true)} - onBlur={() => setSearchFocused(false)} + className={`chat-header-search ${searchActive ? 'focused' : ''}`} + value={searchQuery || ''} + onChange={(e) => onSearchQueryChange?.(e.target.value)} + onFocus={onSearchFocus} + onKeyDown={handleSearchKeyDown} /> + {searchQuery && ( + + )}
)}
diff --git a/packages/shared/src/components/SearchDropdown.jsx b/packages/shared/src/components/SearchDropdown.jsx new file mode 100644 index 0000000..8141640 --- /dev/null +++ b/packages/shared/src/components/SearchDropdown.jsx @@ -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 ( + + + + ); + case 'at': + return ( + + + + ); + case 'has': + return ( + + + + ); + case 'channel': + return #; + case 'date': + return ( + + + + ); + case 'pin': + return ( + + + + ); + 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 = ( +
+
{headerText}
+ {filtered.length === 0 && ( +
No matching users
+ )} + {filtered.map(m => ( +
onSelectFilter(activePrefix.prefix, m.username)} + > + {m.avatarUrl ? ( + + ) : ( +
+ {m.username[0]?.toUpperCase()} +
+ )} + {m.username} +
+ ))} +
+ ); + } else if (activePrefix?.prefix === 'in') { + const filtered = (channels || []).filter(c => + c.name?.toLowerCase().includes(activePrefix.partial) && c.type === 'text' + ); + content = ( +
+
IN CHANNEL
+ {filtered.length === 0 && ( +
No matching channels
+ )} + {filtered.map(c => ( +
onSelectFilter('in', c.name)} + > + # + {c.name} +
+ ))} +
+ ); + } else if (activePrefix?.prefix === 'has') { + const filtered = HAS_OPTIONS.filter(o => + o.value.includes(activePrefix.partial) + ); + content = ( +
+
MESSAGE CONTAINS
+ {filtered.map(o => ( +
onSelectFilter('has', o.value)} + > + + {o.label} +
+ ))} +
+ ); + } else { + // Default: show filter suggestions + search history + content = ( +
+
SEARCH OPTIONS
+ {FILTER_SUGGESTIONS.map(f => ( +
onSelectFilter(f.prefix.replace(':', ''), null)} + > + + {f.label} + {f.description} +
+ ))} + {searchHistory && searchHistory.length > 0 && ( + <> +
+ SEARCH HISTORY + +
+ {searchHistory.map((item, i) => ( +
onSelectHistoryItem(item)} + > + + + + {item} + +
+ ))} + + )} +
+ ); + } + + return ReactDOM.createPortal( +
+ {content} +
, + document.body + ); +}; + +export default SearchDropdown; diff --git a/packages/shared/src/components/SearchPanel.jsx b/packages/shared/src/components/SearchPanel.jsx new file mode 100644 index 0000000..31a913d --- /dev/null +++ b/packages/shared/src/components/SearchPanel.jsx @@ -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, '&').replace(//g, '>'); +} + +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
Loading image...
; + if (error) return null; + return {metadata.filename}; +}; + +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
Loading video...
; + if (error) return null; + + const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); }; + return ( +
+
+ ); +}; + +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 ( +
+ 📄 +
+
{metadata.filename}
+ {sizeStr &&
{sizeStr}
} + {url && e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download} + {loading &&
Decrypting...
} +
+
+ ); +}; + +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 ( +
+
+
+ + {results.length} result{results.length !== 1 ? 's' : ''} + +
+
+
+ + {showSortMenu && ( +
+
{ onSortChange('newest'); setShowSortMenu(false); }} + > + Newest +
+
{ onSortChange('oldest'); setShowSortMenu(false); }} + > + Oldest +
+
+ )} +
+ +
+
+ + {filterChips.length > 0 && ( +
+ {filterChips.map(chip => ( + + {chip.label} + + ))} +
+ )} + +
+ {!isReady && ( +
Search database is loading...
+ )} + {isReady && searching &&
Searching...
} + {isReady && !searching && results.length === 0 && ( +
+ + + +
No results found
+
+ )} + {Object.entries(grouped).map(([chName, msgs]) => ( +
+
{isDM ? chName : `#${chName}`}
+ {msgs.map(r => ( +
handleResultClick(r)} + > +
+ {r.username?.[0]?.toUpperCase()} +
+
+
+ {r.username} + {formatTime(r.created_at)} +
+ {!(r.has_attachment && r.attachment_meta) && ( +
+ )} + {r.has_attachment && r.attachment_meta ? (() => { + try { + const meta = JSON.parse(r.attachment_meta); + if (r.attachment_type?.startsWith('image/')) return ; + if (r.attachment_type?.startsWith('video/')) return ; + return ; + } catch { return File; } + })() : r.has_attachment ? File : null} + {r.has_link && Link} + {r.pinned && Pinned} +
+
+ ))} +
+ ))} +
+
+ ); +}; + +export default SearchPanel; diff --git a/packages/shared/src/components/Sidebar.jsx b/packages/shared/src/components/Sidebar.jsx index cf6b31b..341b49c 100644 --- a/packages/shared/src/components/Sidebar.jsx +++ b/packages/shared/src/components/Sidebar.jsx @@ -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
{ 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 }); } }}> diff --git a/packages/shared/src/components/UserSettings.jsx b/packages/shared/src/components/UserSettings.jsx index d902671..e0fca33 100644 --- a/packages/shared/src/components/UserSettings.jsx +++ b/packages/shared/src/components/UserSettings.jsx @@ -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' && } {activeTab === 'voice' && } {activeTab === 'keybinds' && } + {activeTab === 'search' && }
{/* 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 ( +
+

Search

+ +
+

+ Search Index +

+

+ Rebuild your local search index by downloading and decrypting all messages from the server. This may take a while for large servers. +

+ + {status === 'idle' && ( + + )} + + {status === 'rebuilding' && ( +
+ {/* Progress bar */} +
+
0 + ? `${(progress.channelIndex / progress.totalChannels) * 100}%` + : '0%', + transition: 'width 0.3s ease', + }} /> +
+ +
+ Indexing {progress.currentChannel}... ({progress.channelIndex} of {progress.totalChannels} channels) +
+
+ {formatNumber(progress.messagesIndexed)} messages indexed +
+ + +
+ )} + + {status === 'done' && ( +
+
+ Complete! {formatNumber(progress.messagesIndexed)} messages indexed across {progress.totalChannels} channels. +
+ +
+ )} + + {status === 'error' && ( +
+
+ Error: {errorMsg} +
+ +
+ )} +
+
+ ); +}; + export default UserSettings; diff --git a/packages/shared/src/contexts/SearchContext.jsx b/packages/shared/src/contexts/SearchContext.jsx new file mode 100644 index 0000000..d03cc54 --- /dev/null +++ b/packages/shared/src/contexts/SearchContext.jsx @@ -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 ( + + {children} + + ); +} + +export function useSearch() { + return useContext(SearchContext); +} diff --git a/packages/shared/src/index.css b/packages/shared/src/index.css index 894e3f2..d48db26 100644 --- a/packages/shared/src/index.css +++ b/packages/shared/src/index.css @@ -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; } \ No newline at end of file diff --git a/packages/shared/src/pages/Chat.jsx b/packages/shared/src/pages/Chat.jsx index 7b7c801..2171026 100644 --- a/packages/shared/src/pages/Chat.jsx +++ b/packages/shared/src/pages/Chat.jsx @@ -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} />
{ showPinned={showPinned} onTogglePinned={() => setShowPinned(false)} /> +
); @@ -277,6 +430,7 @@ const Chat = () => { serverName={serverName} isMobile={isMobile} onMobileBack={handleMobileBack} + {...searchProps} />
{ visible={effectiveShowMembers} onMemberClick={(member) => {}} /> +
); @@ -359,6 +522,21 @@ const Chat = () => { )} {showMainContent && renderMainContent()} {showPiP && } + {showSearchDropdown && !isMobile && ( + + )}
diff --git a/packages/shared/src/pages/Login.jsx b/packages/shared/src/pages/Login.jsx index 1e08653..ade7fa2 100644 --- a/packages/shared/src/pages/Login.jsx +++ b/packages/shared/src/pages/Login.jsx @@ -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); diff --git a/packages/shared/src/platform/types.js b/packages/shared/src/platform/types.js index c5d7c3e..820abe1 100644 --- a/packages/shared/src/platform/types.js +++ b/packages/shared/src/platform/types.js @@ -57,11 +57,24 @@ * @property {() => Promise} checkUpdate */ +/** + * @typedef {Object} PlatformSearchDB + * @property {(dbKeyHex: string, userId: string) => Promise} open + * @property {() => Promise} close + * @property {() => Promise} 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 */ diff --git a/packages/shared/src/utils/SearchDatabase.js b/packages/shared/src/utils/SearchDatabase.js new file mode 100644 index 0000000..0611d07 --- /dev/null +++ b/packages/shared/src/utils/SearchDatabase.js @@ -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 + slice = slice.replace(/&/g, '&').replace(//g, '>'); + for (const word of queryWords) { + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + slice = slice.replace(new RegExp(escaped, 'gi'), m => `${m}`); + } + 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; +} diff --git a/packages/shared/src/utils/searchUtils.js b/packages/shared/src/utils/searchUtils.js new file mode 100644 index 0000000..7fd5fe0 --- /dev/null +++ b/packages/shared/src/utils/searchUtils.js @@ -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; +}