feat: Introduce multi-platform architecture for Electron and Web clients with shared UI components, Convex backend for messaging, and integrated search functionality.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
48
TODO.md
48
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);"
|
||||
|
||||
<!-- - When a user messages you, you should get a notification. On the server list that user profile picture should be their above all servers. right under the discord and above the server-separator. With a red dot next to it. If you get a private dm you should hear the ping sound also -->
|
||||
|
||||
<!-- - We should play a sound (the ping sound) when a user mentions you or you recieve a private message. -->
|
||||
|
||||
<!-- - In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages. -->
|
||||
|
||||
<!-- - Owners should be able to delete anyones message in the server. -->
|
||||
|
||||
<!-- - When i share my screen using the Share Screen button thats in our side bar with the disconnect button i dont hear the sharing screen sound like i started sharing. I only hear it when i use the screenshare button in the voice stage modal.
|
||||
|
||||
- Add audio to screenshare -->
|
||||
<!-- - Figure out why audio is shit. -->
|
||||
- Fix green status not updating correctly
|
||||
<!-- - Move people between voice channels. -->
|
||||
<!-- - Allow copy paste of images using CTRL + V in the message box to attach an iamge. -->
|
||||
|
||||
<!-- - If you go afk for 5min switch to idel channel -->
|
||||
|
||||
<!-- - Add server muting. Forcing user to mute. -->
|
||||
|
||||
<!-- - Allow users to mute other users for themself only. I want to be able to allow users to mute other users for themself only and no one else. So if we click the button button in the popup that we get for when we right click on a user and click mute we will mute their voice audio. Can we also update that menu i have a snippit server mute setting snippit.txt inside the discord-html-copy folder. Where they have a checkbox that shows when that mute is on or off. Also when we mute someone we put the personal_mute.svg icon on them. If they are muted themself we show this icon rather than the mute.svg icon. -->
|
||||
|
||||
<!-- - Independient voice volumes per user. -->
|
||||
|
||||
<!-- - We have it so if a user is in a voice channel on the memebers list it shows a status as "In voice" with a icon. Can we do the same when they are streaming. Where its the streaming icon and says "Sharing their screen" We will use the sharing.svg icon. -->
|
||||
|
||||
# Future
|
||||
|
||||
<!-- - Can we allow users to add custom join sounds. Right now we have a default join sound. Can we make it so users can upload their own join sound? In their user settings. They can upload a audio file and it will be used as their join sound instead of the default join sound. -->
|
||||
|
||||
<!-- - Make people type passwords twice to make sure they dont mess up typing their password for registration. -->
|
||||
|
||||
<!-- How can we save user preferences for the app like individual user volumes, the position and size they have the floating stream popout, if they have categories collaped, the last channel they were in so we can open that channel when they open the app, etc. -->
|
||||
|
||||
<!-- - Lets make it so we can upload a custom image for the server that will show on the sidebar. Make the image editing like how we do it for avatars but instead of a circle that we have to show users cut off its a square with a border radius, match it to the boarder radius of the server-item-wrapper -->
|
||||
|
||||
|
||||
- On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile.
|
||||
|
||||
<!-- - WHen i go idle and then come back sometimes it dosent show im online again, or maybe im idle and close the app and relaunch it, it says im idle still and i have to manualy change to online. -->
|
||||
- Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again.
|
||||
|
||||
- Lets make a Popup menu on chat input to paste image or anything from clipboard. So its on option called "Paste". Should only popup if you right click the chat input box.
|
||||
- Add photo / video albums like Commit https://commet.chat/
|
||||
|
||||
<!-- - If app is in the background or minimized it dosent show im online shows im offline -->
|
||||
when i do filter image it has some other stuff like a .html file and a video file. In the search results but we shouldent show non images in the image filter. Videos should have its own filter and .html and other files should go under the file filter, but images should not go under the file filter. We should also show the video and file attachment like how we show it in the chat.
|
||||
|
||||
<!-- - When someone is sharing their screen and we are getting their audio lets ignore any sounds coming from our app. -->
|
||||
|
||||
<!-- - Custom intro sounds repeat when another user joins the chat even though the user with that custom sound didnt join and is already in voice chat.
|
||||
|
||||
- In the user options when you right click on a user in voice we should have the option to disconnect user from voice channel -->
|
||||
|
||||
<!-- - Can we Add volume slider for screenshare audio, and add a fullscreen button for screenshare, where we fullscreen the voice stage. -->
|
||||
|
||||
- Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again.
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlatformProvider } from '@discord-clone/shared/src/platform';
|
||||
import App from '@discord-clone/shared/src/App';
|
||||
import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext';
|
||||
import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext';
|
||||
import { SearchProvider } from '@discord-clone/shared/src/contexts/SearchContext';
|
||||
import { UpdateProvider } from '@discord-clone/shared/src/components/UpdateBanner';
|
||||
import TitleBar from '@discord-clone/shared/src/components/TitleBar';
|
||||
import electronPlatform from './platform';
|
||||
@@ -20,12 +21,14 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<ThemeProvider>
|
||||
<UpdateProvider>
|
||||
<ConvexProvider client={convex}>
|
||||
<VoiceProvider>
|
||||
<TitleBar />
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</VoiceProvider>
|
||||
<SearchProvider>
|
||||
<VoiceProvider>
|
||||
<TitleBar />
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</VoiceProvider>
|
||||
</SearchProvider>
|
||||
</ConvexProvider>
|
||||
</UpdateProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlatformProvider } from '@discord-clone/shared/src/platform';
|
||||
import App from '@discord-clone/shared/src/App';
|
||||
import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext';
|
||||
import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext';
|
||||
import { SearchProvider } from '@discord-clone/shared/src/contexts/SearchContext';
|
||||
import webPlatform from '@discord-clone/platform-web';
|
||||
import '@discord-clone/shared/src/styles/themes.css';
|
||||
import '@discord-clone/shared/src/index.css';
|
||||
@@ -17,11 +18,13 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<PlatformProvider platform={webPlatform}>
|
||||
<ThemeProvider>
|
||||
<ConvexProvider client={convex}>
|
||||
<VoiceProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</VoiceProvider>
|
||||
<SearchProvider>
|
||||
<VoiceProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</VoiceProvider>
|
||||
</SearchProvider>
|
||||
</ConvexProvider>
|
||||
</ThemeProvider>
|
||||
</PlatformProvider>
|
||||
|
||||
383
convex/auth.js
383
convex/auth.js
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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,
|
||||
}); })];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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 }];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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;
|
||||
158
convex/dms.js
158
convex/dms.js
@@ -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; })];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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()];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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: [] }];
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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 }];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
330
convex/roles.js
330
convex/roles.js
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
123
convex/schema.js
123
convex/schema.js
@@ -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")),
|
||||
}),
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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)];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
167
convex/typing.js
167
convex/typing.js
@@ -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];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -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 }];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
@@ -1,456 +0,0 @@
|
||||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __rest = (this && this.__rest) || function (s, e) {
|
||||
var t = {};
|
||||
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
||||
t[p] = s[p];
|
||||
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
||||
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
||||
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
||||
t[p[i]] = s[p[i]];
|
||||
}
|
||||
return t;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.moveUser = exports.disconnectUser = exports.afkMove = exports.getAll = exports.setWatchingStream = exports.serverMute = exports.updateState = exports.leave = exports.join = void 0;
|
||||
var server_1 = require("./_generated/server");
|
||||
var values_1 = require("convex/values");
|
||||
var storageUrl_1 = require("./storageUrl");
|
||||
var roles_1 = require("./roles");
|
||||
function removeUserVoiceStates(ctx, userId) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var existing, _i, existing_1, vs;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ctx.db
|
||||
.query("voiceStates")
|
||||
.withIndex("by_user", function (q) { return q.eq("userId", userId); })
|
||||
.collect()];
|
||||
case 1:
|
||||
existing = _a.sent();
|
||||
_i = 0, existing_1 = existing;
|
||||
_a.label = 2;
|
||||
case 2:
|
||||
if (!(_i < existing_1.length)) return [3 /*break*/, 5];
|
||||
vs = existing_1[_i];
|
||||
return [4 /*yield*/, ctx.db.delete(vs._id)];
|
||||
case 3:
|
||||
_a.sent();
|
||||
_a.label = 4;
|
||||
case 4:
|
||||
_i++;
|
||||
return [3 /*break*/, 2];
|
||||
case 5: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
exports.join = (0, server_1.mutation)({
|
||||
args: {
|
||||
channelId: values_1.v.id("channels"),
|
||||
userId: values_1.v.id("userProfiles"),
|
||||
username: values_1.v.string(),
|
||||
isMuted: values_1.v.boolean(),
|
||||
isDeafened: values_1.v.boolean(),
|
||||
},
|
||||
returns: values_1.v.null(),
|
||||
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, removeUserVoiceStates(ctx, args.userId)];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, ctx.db.insert("voiceStates", {
|
||||
channelId: args.channelId,
|
||||
userId: args.userId,
|
||||
username: args.username,
|
||||
isMuted: args.isMuted,
|
||||
isDeafened: args.isDeafened,
|
||||
isScreenSharing: false,
|
||||
isServerMuted: false,
|
||||
})];
|
||||
case 2:
|
||||
_a.sent();
|
||||
return [2 /*return*/, null];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
exports.leave = (0, server_1.mutation)({
|
||||
args: {
|
||||
userId: values_1.v.id("userProfiles"),
|
||||
},
|
||||
returns: values_1.v.null(),
|
||||
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, removeUserVoiceStates(ctx, args.userId)];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [2 /*return*/, null];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
exports.updateState = (0, server_1.mutation)({
|
||||
args: {
|
||||
userId: values_1.v.id("userProfiles"),
|
||||
isMuted: values_1.v.optional(values_1.v.boolean()),
|
||||
isDeafened: values_1.v.optional(values_1.v.boolean()),
|
||||
isScreenSharing: values_1.v.optional(values_1.v.boolean()),
|
||||
},
|
||||
returns: values_1.v.null(),
|
||||
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var existing, _1, updates, filtered, allStates, _i, allStates_1, s;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ctx.db
|
||||
.query("voiceStates")
|
||||
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
|
||||
.first()];
|
||||
case 1:
|
||||
existing = _a.sent();
|
||||
if (!existing) return [3 /*break*/, 7];
|
||||
_1 = args.userId, updates = __rest(args, ["userId"]);
|
||||
filtered = Object.fromEntries(Object.entries(updates).filter(function (_a) {
|
||||
var val = _a[1];
|
||||
return val !== undefined;
|
||||
}));
|
||||
return [4 /*yield*/, ctx.db.patch(existing._id, filtered)];
|
||||
case 2:
|
||||
_a.sent();
|
||||
if (!(args.isScreenSharing === false)) return [3 /*break*/, 7];
|
||||
return [4 /*yield*/, ctx.db.query("voiceStates").collect()];
|
||||
case 3:
|
||||
allStates = _a.sent();
|
||||
_i = 0, allStates_1 = allStates;
|
||||
_a.label = 4;
|
||||
case 4:
|
||||
if (!(_i < allStates_1.length)) return [3 /*break*/, 7];
|
||||
s = allStates_1[_i];
|
||||
if (!(s.watchingStream === args.userId)) return [3 /*break*/, 6];
|
||||
return [4 /*yield*/, ctx.db.patch(s._id, { watchingStream: undefined })];
|
||||
case 5:
|
||||
_a.sent();
|
||||
_a.label = 6;
|
||||
case 6:
|
||||
_i++;
|
||||
return [3 /*break*/, 4];
|
||||
case 7: return [2 /*return*/, null];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
exports.serverMute = (0, server_1.mutation)({
|
||||
args: {
|
||||
actorUserId: values_1.v.id("userProfiles"),
|
||||
targetUserId: values_1.v.id("userProfiles"),
|
||||
isServerMuted: values_1.v.boolean(),
|
||||
},
|
||||
returns: values_1.v.null(),
|
||||
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var roles, canMute, existing;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.actorUserId)];
|
||||
case 1:
|
||||
roles = _a.sent();
|
||||
canMute = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["mute_members"]; });
|
||||
if (!canMute) {
|
||||
throw new Error("You don't have permission to server mute members");
|
||||
}
|
||||
return [4 /*yield*/, ctx.db
|
||||
.query("voiceStates")
|
||||
.withIndex("by_user", function (q) { return q.eq("userId", args.targetUserId); })
|
||||
.first()];
|
||||
case 2:
|
||||
existing = _a.sent();
|
||||
if (!existing)
|
||||
throw new Error("Target user is not in a voice channel");
|
||||
return [4 /*yield*/, ctx.db.patch(existing._id, { isServerMuted: args.isServerMuted })];
|
||||
case 3:
|
||||
_a.sent();
|
||||
return [2 /*return*/, null];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
exports.setWatchingStream = (0, server_1.mutation)({
|
||||
args: {
|
||||
userId: values_1.v.id("userProfiles"),
|
||||
watchingStream: values_1.v.optional(values_1.v.id("userProfiles")),
|
||||
},
|
||||
returns: values_1.v.null(),
|
||||
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var existing;
|
||||
var _a;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0: return [4 /*yield*/, ctx.db
|
||||
.query("voiceStates")
|
||||
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
|
||||
.first()];
|
||||
case 1:
|
||||
existing = _b.sent();
|
||||
if (!existing) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, ctx.db.patch(existing._id, {
|
||||
watchingStream: (_a = args.watchingStream) !== null && _a !== void 0 ? _a : undefined,
|
||||
})];
|
||||
case 2:
|
||||
_b.sent();
|
||||
_b.label = 3;
|
||||
case 3: return [2 /*return*/, null];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
exports.getAll = (0, server_1.query)({
|
||||
args: {},
|
||||
returns: values_1.v.any(),
|
||||
handler: function (ctx) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var states, grouped, _i, states_1, s, user, avatarUrl, joinSoundUrl;
|
||||
var _a, _b;
|
||||
var _c;
|
||||
return __generator(this, function (_d) {
|
||||
switch (_d.label) {
|
||||
case 0: return [4 /*yield*/, ctx.db.query("voiceStates").collect()];
|
||||
case 1:
|
||||
states = _d.sent();
|
||||
grouped = {};
|
||||
_i = 0, states_1 = states;
|
||||
_d.label = 2;
|
||||
case 2:
|
||||
if (!(_i < states_1.length)) return [3 /*break*/, 9];
|
||||
s = states_1[_i];
|
||||
return [4 /*yield*/, ctx.db.get(s.userId)];
|
||||
case 3:
|
||||
user = _d.sent();
|
||||
avatarUrl = null;
|
||||
if (!(user === null || user === void 0 ? void 0 : user.avatarStorageId)) return [3 /*break*/, 5];
|
||||
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, user.avatarStorageId)];
|
||||
case 4:
|
||||
avatarUrl = _d.sent();
|
||||
_d.label = 5;
|
||||
case 5:
|
||||
joinSoundUrl = null;
|
||||
if (!(user === null || user === void 0 ? void 0 : user.joinSoundStorageId)) return [3 /*break*/, 7];
|
||||
return [4 /*yield*/, (0, storageUrl_1.getPublicStorageUrl)(ctx, user.joinSoundStorageId)];
|
||||
case 6:
|
||||
joinSoundUrl = _d.sent();
|
||||
_d.label = 7;
|
||||
case 7:
|
||||
((_a = grouped[_c = s.channelId]) !== null && _a !== void 0 ? _a : (grouped[_c] = [])).push({
|
||||
userId: s.userId,
|
||||
username: s.username,
|
||||
isMuted: s.isMuted,
|
||||
isDeafened: s.isDeafened,
|
||||
isScreenSharing: s.isScreenSharing,
|
||||
isServerMuted: s.isServerMuted,
|
||||
avatarUrl: avatarUrl,
|
||||
joinSoundUrl: joinSoundUrl,
|
||||
watchingStream: (_b = s.watchingStream) !== null && _b !== void 0 ? _b : null,
|
||||
});
|
||||
_d.label = 8;
|
||||
case 8:
|
||||
_i++;
|
||||
return [3 /*break*/, 2];
|
||||
case 9: return [2 /*return*/, grouped];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
exports.afkMove = (0, server_1.mutation)({
|
||||
args: {
|
||||
userId: values_1.v.id("userProfiles"),
|
||||
afkChannelId: values_1.v.id("channels"),
|
||||
},
|
||||
returns: values_1.v.null(),
|
||||
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var settings, currentState, allStates, _i, allStates_2, s;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ctx.db.query("serverSettings").first()];
|
||||
case 1:
|
||||
settings = _a.sent();
|
||||
if (!settings || settings.afkChannelId !== args.afkChannelId) {
|
||||
throw new Error("Invalid AFK channel");
|
||||
}
|
||||
return [4 /*yield*/, ctx.db
|
||||
.query("voiceStates")
|
||||
.withIndex("by_user", function (q) { return q.eq("userId", args.userId); })
|
||||
.first()];
|
||||
case 2:
|
||||
currentState = _a.sent();
|
||||
// No-op if not in voice or already in AFK channel
|
||||
if (!currentState || currentState.channelId === args.afkChannelId)
|
||||
return [2 /*return*/, null];
|
||||
// Move to AFK channel: delete old state, insert new one muted
|
||||
return [4 /*yield*/, ctx.db.delete(currentState._id)];
|
||||
case 3:
|
||||
// Move to AFK channel: delete old state, insert new one muted
|
||||
_a.sent();
|
||||
return [4 /*yield*/, ctx.db.insert("voiceStates", {
|
||||
channelId: args.afkChannelId,
|
||||
userId: args.userId,
|
||||
username: currentState.username,
|
||||
isMuted: true,
|
||||
isDeafened: currentState.isDeafened,
|
||||
isScreenSharing: false,
|
||||
isServerMuted: currentState.isServerMuted,
|
||||
})];
|
||||
case 4:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, ctx.db.query("voiceStates").collect()];
|
||||
case 5:
|
||||
allStates = _a.sent();
|
||||
_i = 0, allStates_2 = allStates;
|
||||
_a.label = 6;
|
||||
case 6:
|
||||
if (!(_i < allStates_2.length)) return [3 /*break*/, 9];
|
||||
s = allStates_2[_i];
|
||||
if (!(s.watchingStream === args.userId)) return [3 /*break*/, 8];
|
||||
return [4 /*yield*/, ctx.db.patch(s._id, { watchingStream: undefined })];
|
||||
case 7:
|
||||
_a.sent();
|
||||
_a.label = 8;
|
||||
case 8:
|
||||
_i++;
|
||||
return [3 /*break*/, 6];
|
||||
case 9: return [2 /*return*/, null];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
exports.disconnectUser = (0, server_1.mutation)({
|
||||
args: {
|
||||
actorUserId: values_1.v.id("userProfiles"),
|
||||
targetUserId: values_1.v.id("userProfiles"),
|
||||
},
|
||||
returns: values_1.v.null(),
|
||||
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var roles, canMove, allStates, _i, allStates_3, s;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.actorUserId)];
|
||||
case 1:
|
||||
roles = _a.sent();
|
||||
canMove = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["move_members"]; });
|
||||
if (!canMove) {
|
||||
throw new Error("You don't have permission to disconnect members");
|
||||
}
|
||||
return [4 /*yield*/, ctx.db.query("voiceStates").collect()];
|
||||
case 2:
|
||||
allStates = _a.sent();
|
||||
_i = 0, allStates_3 = allStates;
|
||||
_a.label = 3;
|
||||
case 3:
|
||||
if (!(_i < allStates_3.length)) return [3 /*break*/, 6];
|
||||
s = allStates_3[_i];
|
||||
if (!(s.watchingStream === args.targetUserId)) return [3 /*break*/, 5];
|
||||
return [4 /*yield*/, ctx.db.patch(s._id, { watchingStream: undefined })];
|
||||
case 4:
|
||||
_a.sent();
|
||||
_a.label = 5;
|
||||
case 5:
|
||||
_i++;
|
||||
return [3 /*break*/, 3];
|
||||
case 6: return [4 /*yield*/, removeUserVoiceStates(ctx, args.targetUserId)];
|
||||
case 7:
|
||||
_a.sent();
|
||||
return [2 /*return*/, null];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
exports.moveUser = (0, server_1.mutation)({
|
||||
args: {
|
||||
actorUserId: values_1.v.id("userProfiles"),
|
||||
targetUserId: values_1.v.id("userProfiles"),
|
||||
targetChannelId: values_1.v.id("channels"),
|
||||
},
|
||||
returns: values_1.v.null(),
|
||||
handler: function (ctx, args) { return __awaiter(void 0, void 0, void 0, function () {
|
||||
var roles, canMove, targetChannel, currentState;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, (0, roles_1.getRolesForUser)(ctx, args.actorUserId)];
|
||||
case 1:
|
||||
roles = _a.sent();
|
||||
canMove = roles.some(function (role) { var _a; return (_a = role.permissions) === null || _a === void 0 ? void 0 : _a["move_members"]; });
|
||||
if (!canMove) {
|
||||
throw new Error("You don't have permission to move members");
|
||||
}
|
||||
return [4 /*yield*/, ctx.db.get(args.targetChannelId)];
|
||||
case 2:
|
||||
targetChannel = _a.sent();
|
||||
if (!targetChannel)
|
||||
throw new Error("Target channel not found");
|
||||
if (targetChannel.type !== "voice")
|
||||
throw new Error("Target channel is not a voice channel");
|
||||
return [4 /*yield*/, ctx.db
|
||||
.query("voiceStates")
|
||||
.withIndex("by_user", function (q) { return q.eq("userId", args.targetUserId); })
|
||||
.first()];
|
||||
case 3:
|
||||
currentState = _a.sent();
|
||||
if (!currentState)
|
||||
throw new Error("Target user is not in a voice channel");
|
||||
// No-op if already in the target channel
|
||||
if (currentState.channelId === args.targetChannelId)
|
||||
return [2 /*return*/, null];
|
||||
// Delete old voice state and insert new one preserving mute/deaf/screenshare
|
||||
return [4 /*yield*/, ctx.db.delete(currentState._id)];
|
||||
case 4:
|
||||
// Delete old voice state and insert new one preserving mute/deaf/screenshare
|
||||
_a.sent();
|
||||
return [4 /*yield*/, ctx.db.insert("voiceStates", {
|
||||
channelId: args.targetChannelId,
|
||||
userId: args.targetUserId,
|
||||
username: currentState.username,
|
||||
isMuted: currentState.isMuted,
|
||||
isDeafened: currentState.isDeafened,
|
||||
isScreenSharing: currentState.isScreenSharing,
|
||||
isServerMuted: currentState.isServerMuted,
|
||||
})];
|
||||
case 5:
|
||||
_a.sent();
|
||||
return [2 /*return*/, null];
|
||||
}
|
||||
});
|
||||
}); },
|
||||
});
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
54
packages/platform-web/src/searchStorage.js
Normal file
54
packages/platform-web/src/searchStorage.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const DB_NAME = 'discord-clone-search';
|
||||
const STORE_NAME = 'databases';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function openIDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
const searchStorage = {
|
||||
async load(userId) {
|
||||
const db = await openIDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(`search-db-${userId}`);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async save(userId, bytes) {
|
||||
const db = await openIDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.put(bytes, `search-db-${userId}`);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
|
||||
async clear(userId) {
|
||||
const db = await openIDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.delete(`search-db-${userId}`);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default searchStorage;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, showMembers, onTogglePinned, serverName, isMobile, onMobileBack }) => {
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
|
||||
const ChatHeader = ({
|
||||
channelName,
|
||||
channelType,
|
||||
channelTopic,
|
||||
onToggleMembers,
|
||||
showMembers,
|
||||
onTogglePinned,
|
||||
serverName,
|
||||
isMobile,
|
||||
onMobileBack,
|
||||
// Search props
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onSearchSubmit,
|
||||
onSearchFocus,
|
||||
onSearchBlur,
|
||||
searchInputRef,
|
||||
searchActive,
|
||||
}) => {
|
||||
const isDM = channelType === 'dm';
|
||||
const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`;
|
||||
|
||||
const handleSearchKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSearchSubmit?.();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onSearchBlur?.();
|
||||
e.target.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-header">
|
||||
<div className="chat-header-left">
|
||||
@@ -65,14 +93,29 @@ const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, s
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div className="chat-header-search-wrapper">
|
||||
<div className="chat-header-search-wrapper" ref={searchInputRef}>
|
||||
<svg className="chat-header-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
className={`chat-header-search ${searchFocused ? 'focused' : ''}`}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
className={`chat-header-search ${searchActive ? 'focused' : ''}`}
|
||||
value={searchQuery || ''}
|
||||
onChange={(e) => onSearchQueryChange?.(e.target.value)}
|
||||
onFocus={onSearchFocus}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="chat-header-search-clear"
|
||||
onClick={() => onSearchQueryChange?.('')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
244
packages/shared/src/components/SearchDropdown.jsx
Normal file
244
packages/shared/src/components/SearchDropdown.jsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { detectActivePrefix } from '../utils/searchUtils';
|
||||
|
||||
const FILTER_SUGGESTIONS = [
|
||||
{ prefix: 'from:', label: 'from:', description: 'user', icon: 'user' },
|
||||
{ prefix: 'mentions:', label: 'mentions:', description: 'user', icon: 'at' },
|
||||
{ prefix: 'has:', label: 'has:', description: 'link, file, image, or video', icon: 'has' },
|
||||
{ prefix: 'in:', label: 'in:', description: 'channel', icon: 'channel' },
|
||||
{ prefix: 'before:', label: 'before:', description: 'date', icon: 'date' },
|
||||
{ prefix: 'after:', label: 'after:', description: 'date', icon: 'date' },
|
||||
{ prefix: 'pinned:', label: 'pinned:', description: 'true or false', icon: 'pin' },
|
||||
];
|
||||
|
||||
const HAS_OPTIONS = [
|
||||
{ value: 'link', label: 'link' },
|
||||
{ value: 'file', label: 'file' },
|
||||
{ value: 'image', label: 'image' },
|
||||
{ value: 'video', label: 'video' },
|
||||
];
|
||||
|
||||
function getAvatarColor(name) {
|
||||
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
function FilterIcon({ type }) {
|
||||
switch (type) {
|
||||
case 'user':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'at':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10h5v-2h-5c-4.34 0-8-3.66-8-8s3.66-8 8-8 8 3.66 8 8v1.43c0 .79-.71 1.57-1.5 1.57s-1.5-.78-1.5-1.57V12c0-2.76-2.24-5-5-5s-5 2.24-5 5 2.24 5 5 5c1.38 0 2.64-.56 3.54-1.47.65.89 1.77 1.47 2.96 1.47 1.97 0 3.5-1.6 3.5-3.57V12c0-5.52-4.48-10-10-10zm0 13c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'has':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'channel':
|
||||
return <span style={{ fontSize: 16, fontWeight: 700, opacity: 0.7 }}>#</span>;
|
||||
case 'date':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'pin':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.3 5.3a1 1 0 00-1.4-1.4L14.6 7.2l-1.5-.8a2 2 0 00-2.2.2L8.5 9a1 1 0 000 1.5l1.8 1.8-4.6 4.6a1 1 0 001.4 1.4l4.6-4.6 1.8 1.8a1 1 0 001.5 0l2.4-2.4a2 2 0 00.2-2.2l-.8-1.5 3.3-3.3z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SearchDropdown = ({
|
||||
visible,
|
||||
searchText,
|
||||
channels,
|
||||
members,
|
||||
searchHistory,
|
||||
onSelectFilter,
|
||||
onSelectHistoryItem,
|
||||
onClearHistory,
|
||||
onClearHistoryItem,
|
||||
anchorRef,
|
||||
onClose,
|
||||
}) => {
|
||||
const dropdownRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 420 });
|
||||
|
||||
// Position dropdown below anchor
|
||||
useEffect(() => {
|
||||
if (!visible || !anchorRef?.current) return;
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
setPos({
|
||||
top: rect.bottom + 4,
|
||||
left: Math.max(rect.right - 420, 8),
|
||||
width: 420,
|
||||
});
|
||||
}, [visible, anchorRef, searchText]);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const handler = (e) => {
|
||||
if (
|
||||
dropdownRef.current && !dropdownRef.current.contains(e.target) &&
|
||||
anchorRef?.current && !anchorRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [visible, onClose, anchorRef]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const activePrefix = detectActivePrefix(searchText);
|
||||
|
||||
let content;
|
||||
|
||||
if (activePrefix?.prefix === 'from' || activePrefix?.prefix === 'mentions') {
|
||||
const filtered = (members || []).filter(m =>
|
||||
m.username.toLowerCase().includes(activePrefix.partial)
|
||||
);
|
||||
const headerText = activePrefix.prefix === 'from' ? 'FROM USER' : 'MENTIONS USER';
|
||||
content = (
|
||||
<div className="search-dropdown-scrollable">
|
||||
<div className="search-dropdown-section-header">{headerText}</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="search-dropdown-empty">No matching users</div>
|
||||
)}
|
||||
{filtered.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="search-dropdown-member"
|
||||
onClick={() => onSelectFilter(activePrefix.prefix, m.username)}
|
||||
>
|
||||
{m.avatarUrl ? (
|
||||
<img src={m.avatarUrl} className="search-dropdown-avatar" alt="" />
|
||||
) : (
|
||||
<div className="search-dropdown-avatar" style={{ backgroundColor: getAvatarColor(m.username) }}>
|
||||
{m.username[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="search-dropdown-member-name">{m.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (activePrefix?.prefix === 'in') {
|
||||
const filtered = (channels || []).filter(c =>
|
||||
c.name?.toLowerCase().includes(activePrefix.partial) && c.type === 'text'
|
||||
);
|
||||
content = (
|
||||
<div className="search-dropdown-scrollable">
|
||||
<div className="search-dropdown-section-header">IN CHANNEL</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="search-dropdown-empty">No matching channels</div>
|
||||
)}
|
||||
{filtered.map(c => (
|
||||
<div
|
||||
key={c._id}
|
||||
className="search-dropdown-channel"
|
||||
onClick={() => onSelectFilter('in', c.name)}
|
||||
>
|
||||
<span className="search-dropdown-channel-hash">#</span>
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (activePrefix?.prefix === 'has') {
|
||||
const filtered = HAS_OPTIONS.filter(o =>
|
||||
o.value.includes(activePrefix.partial)
|
||||
);
|
||||
content = (
|
||||
<div className="search-dropdown-scrollable">
|
||||
<div className="search-dropdown-section-header">MESSAGE CONTAINS</div>
|
||||
{filtered.map(o => (
|
||||
<div
|
||||
key={o.value}
|
||||
className="search-dropdown-item"
|
||||
onClick={() => onSelectFilter('has', o.value)}
|
||||
>
|
||||
<FilterIcon type="has" />
|
||||
<span>{o.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Default: show filter suggestions + search history
|
||||
content = (
|
||||
<div className="search-dropdown-scrollable">
|
||||
<div className="search-dropdown-section-header">SEARCH OPTIONS</div>
|
||||
{FILTER_SUGGESTIONS.map(f => (
|
||||
<div
|
||||
key={f.prefix}
|
||||
className="search-dropdown-item"
|
||||
onClick={() => onSelectFilter(f.prefix.replace(':', ''), null)}
|
||||
>
|
||||
<span className="search-dropdown-item-icon"><FilterIcon type={f.icon} /></span>
|
||||
<span className="search-dropdown-item-label">{f.label}</span>
|
||||
<span className="search-dropdown-item-desc">{f.description}</span>
|
||||
</div>
|
||||
))}
|
||||
{searchHistory && searchHistory.length > 0 && (
|
||||
<>
|
||||
<div className="search-dropdown-section-header search-dropdown-history-header">
|
||||
<span>SEARCH HISTORY</span>
|
||||
<button className="search-dropdown-clear-all" onClick={onClearHistory}>Clear</button>
|
||||
</div>
|
||||
{searchHistory.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="search-dropdown-history-item"
|
||||
onClick={() => onSelectHistoryItem(item)}
|
||||
>
|
||||
<svg className="search-dropdown-history-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 3a9 9 0 00-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0013 21a9 9 0 000-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/>
|
||||
</svg>
|
||||
<span className="search-dropdown-history-text">{item}</span>
|
||||
<button
|
||||
className="search-dropdown-history-delete"
|
||||
onClick={(e) => { e.stopPropagation(); onClearHistoryItem(i); }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="search-dropdown"
|
||||
style={{ top: pos.top, left: pos.left, width: pos.width }}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDropdown;
|
||||
408
packages/shared/src/components/SearchPanel.jsx
Normal file
408
packages/shared/src/components/SearchPanel.jsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { parseFilters } from '../utils/searchUtils';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').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 <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
|
||||
if (error) return null;
|
||||
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
|
||||
};
|
||||
|
||||
const SearchResultVideo = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
const [error, setError] = useState(null);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) throw new Error('Invalid file data');
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search video decrypt error:', err);
|
||||
if (isMounted) { setError('Failed to load'); setLoading(false); }
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
|
||||
if (error) return null;
|
||||
|
||||
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
||||
return (
|
||||
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
||||
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
|
||||
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>▶</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchResultFile = ({ metadata }) => {
|
||||
const { crypto } = usePlatform();
|
||||
const fetchUrl = rewriteStorageUrl(metadata.url);
|
||||
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
|
||||
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
|
||||
|
||||
useEffect(() => {
|
||||
if (searchImageCache.has(fetchUrl)) {
|
||||
setUrl(searchImageCache.get(fetchUrl));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
const res = await fetch(fetchUrl);
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
||||
if (hexInput.length < 32) return;
|
||||
const TAG_HEX_LEN = 32;
|
||||
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
||||
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
||||
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
||||
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
||||
const objectUrl = URL.createObjectURL(decryptedBlob);
|
||||
if (isMounted) {
|
||||
searchImageCache.set(fetchUrl, objectUrl);
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search file decrypt error:', err);
|
||||
if (isMounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
decrypt();
|
||||
return () => { isMounted = false; };
|
||||
}, [fetchUrl, metadata, crypto]);
|
||||
|
||||
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
|
||||
<span style={{ marginRight: 8, fontSize: 20 }}>📄</span>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
|
||||
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
|
||||
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
|
||||
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => {
|
||||
const { search, isReady } = useSearch() || {};
|
||||
const [results, setResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
|
||||
// Execute search when query changes
|
||||
useEffect(() => {
|
||||
if (!visible || !query?.trim() || !search || !isReady) {
|
||||
if (!query?.trim()) setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
const { textQuery, filters } = parseFilters(query);
|
||||
|
||||
let channelId;
|
||||
if (isDM) {
|
||||
// In DM view — always scope to the DM channel
|
||||
channelId = dmChannelId;
|
||||
} else {
|
||||
channelId = filters.channelName
|
||||
? channels?.find(c => c.name?.toLowerCase() === filters.channelName.toLowerCase())?._id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const params = {
|
||||
query: textQuery || undefined,
|
||||
channelId,
|
||||
senderName: filters.senderName,
|
||||
hasLink: filters.hasLink,
|
||||
hasImage: filters.hasImage,
|
||||
hasVideo: filters.hasVideo,
|
||||
hasFile: filters.hasFile,
|
||||
hasMention: filters.hasMention,
|
||||
before: filters.before,
|
||||
after: filters.after,
|
||||
pinned: filters.pinned,
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
const res = search(params);
|
||||
|
||||
let filtered;
|
||||
if (isDM) {
|
||||
// In DM view — results are already scoped to dmChannelId
|
||||
filtered = res;
|
||||
} else {
|
||||
// In server view — filter out DM messages
|
||||
const serverChannelIds = new Set(channels?.map(c => c._id) || []);
|
||||
filtered = res.filter(r => serverChannelIds.has(r.channel_id));
|
||||
}
|
||||
|
||||
// Sort results
|
||||
let sorted = [...filtered];
|
||||
if (sortOrder === 'oldest') {
|
||||
sorted.sort((a, b) => a.created_at - b.created_at);
|
||||
} else {
|
||||
// newest first (default)
|
||||
sorted.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
setResults(sorted);
|
||||
setSearching(false);
|
||||
}, [visible, query, sortOrder, search, isReady, channels, isDM, dmChannelId]);
|
||||
|
||||
const handleResultClick = useCallback((result) => {
|
||||
onJumpToMessage(result.channel_id, result.id);
|
||||
}, [onJumpToMessage]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const channelMap = {};
|
||||
if (channels) {
|
||||
for (const c of channels) channelMap[c._id] = c.name;
|
||||
}
|
||||
|
||||
// Group results by channel
|
||||
const grouped = {};
|
||||
for (const r of results) {
|
||||
const chName = channelMap[r.channel_id] || 'Unknown';
|
||||
if (!grouped[chName]) grouped[chName] = [];
|
||||
grouped[chName].push(r);
|
||||
}
|
||||
|
||||
const { filters: activeFilters } = query?.trim() ? parseFilters(query) : { filters: {} };
|
||||
const filterChips = [];
|
||||
if (activeFilters.senderName) filterChips.push({ label: `from: ${activeFilters.senderName}`, key: 'from' });
|
||||
if (activeFilters.hasLink) filterChips.push({ label: 'has: link', key: 'hasLink' });
|
||||
if (activeFilters.hasImage) filterChips.push({ label: 'has: image', key: 'hasImage' });
|
||||
if (activeFilters.hasVideo) filterChips.push({ label: 'has: video', key: 'hasVideo' });
|
||||
if (activeFilters.hasFile) filterChips.push({ label: 'has: file', key: 'hasFile' });
|
||||
if (activeFilters.hasMention) filterChips.push({ label: 'has: mention', key: 'hasMention' });
|
||||
if (activeFilters.before) filterChips.push({ label: `before: ${activeFilters.before}`, key: 'before' });
|
||||
if (activeFilters.after) filterChips.push({ label: `after: ${activeFilters.after}`, key: 'after' });
|
||||
if (activeFilters.pinned) filterChips.push({ label: 'pinned: true', key: 'pinned' });
|
||||
if (activeFilters.channelName) filterChips.push({ label: `in: ${activeFilters.channelName}`, key: 'in' });
|
||||
|
||||
const sortLabel = sortOrder === 'oldest' ? 'Oldest' : 'Newest';
|
||||
|
||||
return (
|
||||
<div className="search-panel">
|
||||
<div className="search-panel-header">
|
||||
<div className="search-panel-header-left">
|
||||
<span className="search-result-count">
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="search-panel-header-right">
|
||||
<div className="search-panel-sort-wrapper">
|
||||
<button
|
||||
className="search-panel-sort-btn"
|
||||
onClick={() => setShowSortMenu(prev => !prev)}
|
||||
>
|
||||
{sortLabel}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 4 }}>
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{showSortMenu && (
|
||||
<div className="search-panel-sort-menu">
|
||||
<div
|
||||
className={`search-panel-sort-option ${sortOrder === 'newest' ? 'active' : ''}`}
|
||||
onClick={() => { onSortChange('newest'); setShowSortMenu(false); }}
|
||||
>
|
||||
Newest
|
||||
</div>
|
||||
<div
|
||||
className={`search-panel-sort-option ${sortOrder === 'oldest' ? 'active' : ''}`}
|
||||
onClick={() => { onSortChange('oldest'); setShowSortMenu(false); }}
|
||||
>
|
||||
Oldest
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="search-panel-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterChips.length > 0 && (
|
||||
<div className="search-filter-chips">
|
||||
{filterChips.map(chip => (
|
||||
<span key={chip.key} className="search-filter-chip">
|
||||
{chip.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="search-panel-results">
|
||||
{!isReady && (
|
||||
<div className="search-panel-empty">Search database is loading...</div>
|
||||
)}
|
||||
{isReady && searching && <div className="search-panel-empty">Searching...</div>}
|
||||
{isReady && !searching && results.length === 0 && (
|
||||
<div className="search-panel-empty">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.3, marginBottom: 8 }}>
|
||||
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
|
||||
</svg>
|
||||
<div>No results found</div>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(grouped).map(([chName, msgs]) => (
|
||||
<div key={chName}>
|
||||
<div className="search-channel-header">{isDM ? chName : `#${chName}`}</div>
|
||||
{msgs.map(r => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="search-result"
|
||||
onClick={() => handleResultClick(r)}
|
||||
>
|
||||
<div
|
||||
className="search-result-avatar"
|
||||
style={{ backgroundColor: getAvatarColor(r.username) }}
|
||||
>
|
||||
{r.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="search-result-body">
|
||||
<div className="search-result-header">
|
||||
<span className="search-result-username">{r.username}</span>
|
||||
<span className="search-result-time">{formatTime(r.created_at)}</span>
|
||||
</div>
|
||||
{!(r.has_attachment && r.attachment_meta) && (
|
||||
<div
|
||||
className="search-result-content"
|
||||
dangerouslySetInnerHTML={{ __html: r.snippet || escapeHtml(r.content) }}
|
||||
/>
|
||||
)}
|
||||
{r.has_attachment && r.attachment_meta ? (() => {
|
||||
try {
|
||||
const meta = JSON.parse(r.attachment_meta);
|
||||
if (r.attachment_type?.startsWith('image/')) return <SearchResultImage metadata={meta} />;
|
||||
if (r.attachment_type?.startsWith('video/')) return <SearchResultVideo metadata={meta} />;
|
||||
return <SearchResultFile metadata={meta} />;
|
||||
} catch { return <span className="search-result-badge">File</span>; }
|
||||
})() : r.has_attachment ? <span className="search-result-badge">File</span> : null}
|
||||
{r.has_link && <span className="search-result-badge">Link</span>}
|
||||
{r.pinned && <span className="search-result-badge">Pinned</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPanel;
|
||||
@@ -93,7 +93,7 @@ const STATUS_OPTIONS = [
|
||||
];
|
||||
|
||||
const UserControlPanel = React.memo(({ username, userId }) => {
|
||||
const { session, idle } = usePlatform();
|
||||
const { session, idle, searchDB } = usePlatform();
|
||||
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice();
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
const [showUserSettings, setShowUserSettings] = useState(false);
|
||||
@@ -137,6 +137,10 @@ const UserControlPanel = React.memo(({ username, userId }) => {
|
||||
if (connectionState === 'connected') {
|
||||
try { disconnectVoice(); } catch {}
|
||||
}
|
||||
// Save and close search DB
|
||||
if (searchDB?.isOpen()) {
|
||||
try { await searchDB.save(); searchDB.close(); } catch {}
|
||||
}
|
||||
// Clear persisted session
|
||||
if (session) {
|
||||
try { await session.clear(); } catch {}
|
||||
@@ -383,7 +387,8 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
|
||||
useEffect(() => {
|
||||
const h = () => onClose();
|
||||
window.addEventListener('click', h);
|
||||
return () => window.removeEventListener('click', h);
|
||||
window.addEventListener('close-context-menus', h);
|
||||
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); };
|
||||
}, [onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -489,7 +494,8 @@ const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCatego
|
||||
useEffect(() => {
|
||||
const h = () => onClose();
|
||||
window.addEventListener('click', h);
|
||||
return () => window.removeEventListener('click', h);
|
||||
window.addEventListener('close-context-menus', h);
|
||||
return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); };
|
||||
}, [onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -1062,6 +1068,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new Event('close-context-menus'));
|
||||
setVoiceUserMenu({ x: e.clientX, y: e.clientY, user });
|
||||
}}
|
||||
>
|
||||
@@ -1332,6 +1339,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={(e) => {
|
||||
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new Event('close-context-menus'));
|
||||
setChannelListContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
}}>
|
||||
|
||||
@@ -5,6 +5,8 @@ import Avatar from './Avatar';
|
||||
import AvatarCropModal from './AvatarCropModal';
|
||||
import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import { useSearch } from '../contexts/SearchContext';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
const THEME_PREVIEWS = {
|
||||
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
|
||||
@@ -18,6 +20,7 @@ const TABS = [
|
||||
{ id: 'appearance', label: 'Appearance', section: 'USER SETTINGS' },
|
||||
{ id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' },
|
||||
{ id: 'keybinds', label: 'Keybinds', section: 'APP SETTINGS' },
|
||||
{ id: 'search', label: 'Search', section: 'APP SETTINGS' },
|
||||
];
|
||||
|
||||
const UserSettings = ({ onClose, userId, username, onLogout }) => {
|
||||
@@ -111,6 +114,7 @@ const UserSettings = ({ onClose, userId, username, onLogout }) => {
|
||||
{activeTab === 'appearance' && <AppearanceTab />}
|
||||
{activeTab === 'voice' && <VoiceVideoTab />}
|
||||
{activeTab === 'keybinds' && <KeybindsTab />}
|
||||
{activeTab === 'search' && <SearchTab userId={userId} />}
|
||||
</div>
|
||||
|
||||
{/* Right spacer with close button */}
|
||||
@@ -845,4 +849,259 @@ const KeybindsTab = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/* =========================================
|
||||
SEARCH TAB
|
||||
========================================= */
|
||||
const TAG_HEX_LEN = 32;
|
||||
|
||||
const SearchTab = ({ userId }) => {
|
||||
const convex = useConvex();
|
||||
const { crypto } = usePlatform();
|
||||
const searchCtx = useSearch();
|
||||
|
||||
const [status, setStatus] = useState('idle'); // idle | rebuilding | done | error
|
||||
const [progress, setProgress] = useState({ currentChannel: '', channelIndex: 0, totalChannels: 0, messagesIndexed: 0 });
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const handleRebuild = async () => {
|
||||
if (!userId || !crypto || !searchCtx?.isReady) return;
|
||||
|
||||
cancelledRef.current = false;
|
||||
setStatus('rebuilding');
|
||||
setProgress({ currentChannel: '', channelIndex: 0, totalChannels: 0, messagesIndexed: 0 });
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
// 1. Gather channels + DMs
|
||||
const [channels, dmChannels, rawKeys] = await Promise.all([
|
||||
convex.query(api.channels.list, {}),
|
||||
convex.query(api.dms.listDMs, { userId }),
|
||||
convex.query(api.channelKeys.getKeysForUser, { userId }),
|
||||
]);
|
||||
|
||||
// 2. Decrypt channel keys
|
||||
const privateKey = sessionStorage.getItem('privateKey');
|
||||
if (!privateKey) throw new Error('Private key not found in session. Please re-login.');
|
||||
|
||||
const decryptedKeys = {};
|
||||
for (const item of rawKeys) {
|
||||
try {
|
||||
const bundleJson = await crypto.privateDecrypt(privateKey, item.encrypted_key_bundle);
|
||||
Object.assign(decryptedKeys, JSON.parse(bundleJson));
|
||||
} catch (e) {
|
||||
// Skip channels we can't decrypt
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build channel list: text channels + DMs that have keys
|
||||
const textChannels = channels
|
||||
.filter(c => c.type === 'text' && decryptedKeys[c._id])
|
||||
.map(c => ({ id: c._id, name: '#' + c.name, key: decryptedKeys[c._id] }));
|
||||
|
||||
const dmItems = (dmChannels || [])
|
||||
.filter(dm => decryptedKeys[dm.channel_id])
|
||||
.map(dm => ({ id: dm.channel_id, name: '@' + dm.other_username, key: decryptedKeys[dm.channel_id] }));
|
||||
|
||||
const allChannels = [...textChannels, ...dmItems];
|
||||
|
||||
if (allChannels.length === 0) {
|
||||
setStatus('done');
|
||||
setProgress(p => ({ ...p, totalChannels: 0 }));
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(p => ({ ...p, totalChannels: allChannels.length }));
|
||||
|
||||
let totalIndexed = 0;
|
||||
|
||||
// 4. For each channel, paginate and decrypt
|
||||
for (let i = 0; i < allChannels.length; i++) {
|
||||
if (cancelledRef.current) break;
|
||||
|
||||
const ch = allChannels[i];
|
||||
setProgress(p => ({ ...p, currentChannel: ch.name, channelIndex: i + 1 }));
|
||||
|
||||
let cursor = null;
|
||||
let isDone = false;
|
||||
|
||||
while (!isDone) {
|
||||
if (cancelledRef.current) break;
|
||||
|
||||
const paginationOpts = { numItems: 100, cursor };
|
||||
const result = await convex.query(api.messages.fetchBulkPage, {
|
||||
channelId: ch.id,
|
||||
paginationOpts,
|
||||
});
|
||||
|
||||
if (result.page.length > 0) {
|
||||
// Build decrypt batch
|
||||
const decryptItems = [];
|
||||
const msgMap = [];
|
||||
|
||||
for (const msg of result.page) {
|
||||
if (msg.ciphertext && msg.ciphertext.length >= TAG_HEX_LEN) {
|
||||
const tag = msg.ciphertext.slice(-TAG_HEX_LEN);
|
||||
const content = msg.ciphertext.slice(0, -TAG_HEX_LEN);
|
||||
decryptItems.push({ ciphertext: content, key: ch.key, iv: msg.nonce, tag });
|
||||
msgMap.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (decryptItems.length > 0) {
|
||||
const decryptResults = await crypto.decryptBatch(decryptItems);
|
||||
|
||||
const indexItems = [];
|
||||
for (let j = 0; j < decryptResults.length; j++) {
|
||||
const plaintext = decryptResults[j];
|
||||
if (plaintext && plaintext !== '[Decryption Error]') {
|
||||
indexItems.push({
|
||||
id: msgMap[j].id,
|
||||
channel_id: msgMap[j].channel_id,
|
||||
sender_id: msgMap[j].sender_id,
|
||||
username: msgMap[j].username,
|
||||
content: plaintext,
|
||||
created_at: msgMap[j].created_at,
|
||||
pinned: msgMap[j].pinned,
|
||||
replyToId: msgMap[j].replyToId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (indexItems.length > 0) {
|
||||
searchCtx.indexMessages(indexItems);
|
||||
totalIndexed += indexItems.length;
|
||||
setProgress(p => ({ ...p, messagesIndexed: totalIndexed }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isDone = result.isDone;
|
||||
cursor = result.continueCursor;
|
||||
|
||||
// Yield to UI between pages
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Save
|
||||
await searchCtx.save();
|
||||
setStatus(cancelledRef.current ? 'idle' : 'done');
|
||||
setProgress(p => ({ ...p, messagesIndexed: totalIndexed }));
|
||||
} catch (err) {
|
||||
console.error('Search index rebuild failed:', err);
|
||||
setErrorMsg(err.message || 'Unknown error');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
|
||||
const formatNumber = (n) => n.toLocaleString();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Search</h2>
|
||||
|
||||
<div style={{ backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', padding: '20px' }}>
|
||||
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 8px', fontSize: '16px', fontWeight: '600' }}>
|
||||
Search Index
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: '0 0 16px', lineHeight: '1.4' }}>
|
||||
Rebuild your local search index by downloading and decrypting all messages from the server. This may take a while for large servers.
|
||||
</p>
|
||||
|
||||
{status === 'idle' && (
|
||||
<button
|
||||
onClick={handleRebuild}
|
||||
disabled={!searchCtx?.isReady}
|
||||
style={{
|
||||
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
||||
borderRadius: '4px', padding: '10px 20px', cursor: searchCtx?.isReady ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px', fontWeight: '500', opacity: searchCtx?.isReady ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Rebuild Search Index
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === 'rebuilding' && (
|
||||
<div>
|
||||
{/* Progress bar */}
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', height: '8px',
|
||||
overflow: 'hidden', marginBottom: '12px',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: '4px',
|
||||
backgroundColor: 'var(--brand-experiment)',
|
||||
width: progress.totalChannels > 0
|
||||
? `${(progress.channelIndex / progress.totalChannels) * 100}%`
|
||||
: '0%',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div style={{ color: 'var(--text-normal)', fontSize: '14px', marginBottom: '4px' }}>
|
||||
Indexing {progress.currentChannel}... ({progress.channelIndex} of {progress.totalChannels} channels)
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '13px', marginBottom: '12px' }}>
|
||||
{formatNumber(progress.messagesIndexed)} messages indexed
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
backgroundColor: 'transparent', color: '#ed4245', border: '1px solid #ed4245',
|
||||
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
|
||||
fontSize: '14px', fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'done' && (
|
||||
<div>
|
||||
<div style={{ color: '#3ba55c', fontSize: '14px', marginBottom: '12px', fontWeight: '500' }}>
|
||||
Complete! {formatNumber(progress.messagesIndexed)} messages indexed across {progress.totalChannels} channels.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
style={{
|
||||
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
||||
borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
|
||||
fontSize: '14px', fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Rebuild Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div>
|
||||
<div style={{ color: '#ed4245', fontSize: '14px', marginBottom: '12px' }}>
|
||||
Error: {errorMsg}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
style={{
|
||||
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
||||
borderRadius: '4px', padding: '10px 20px', cursor: 'pointer',
|
||||
fontSize: '14px', fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSettings;
|
||||
|
||||
75
packages/shared/src/contexts/SearchContext.jsx
Normal file
75
packages/shared/src/contexts/SearchContext.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { usePlatform } from '../platform';
|
||||
|
||||
const SearchContext = createContext(null);
|
||||
|
||||
export function SearchProvider({ children }) {
|
||||
const { searchDB, features } = usePlatform();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [initSignal, setInitSignal] = useState(0);
|
||||
|
||||
const initialize = useCallback(() => {
|
||||
setInitSignal(s => s + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!features?.hasSearch || !searchDB) return;
|
||||
|
||||
// Already open from a previous run
|
||||
if (searchDB.isOpen()) {
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const dbKey = sessionStorage.getItem('searchDbKey');
|
||||
const userId = localStorage.getItem('userId');
|
||||
if (!dbKey || !userId) return;
|
||||
|
||||
searchDB.open(dbKey, userId)
|
||||
.then(() => {
|
||||
setIsReady(true);
|
||||
console.log('Search DB initialized');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Search DB init failed:', err);
|
||||
});
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
if (searchDB.isOpen()) {
|
||||
searchDB.save();
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [searchDB, features?.hasSearch, initSignal]);
|
||||
|
||||
const indexMessages = useCallback((messages) => {
|
||||
if (!searchDB?.isOpen() || !messages?.length) return;
|
||||
searchDB.indexMessages(messages);
|
||||
}, [searchDB]);
|
||||
|
||||
const search = useCallback((params) => {
|
||||
if (!searchDB?.isOpen()) return [];
|
||||
return searchDB.search(params);
|
||||
}, [searchDB]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (searchDB?.isOpen()) {
|
||||
await searchDB.save();
|
||||
}
|
||||
}, [searchDB]);
|
||||
|
||||
const value = useMemo(() => (
|
||||
{ isReady, indexMessages, search, save, searchDB, initialize }
|
||||
), [isReady, indexMessages, search, save, searchDB, initialize]);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={value}>
|
||||
{children}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSearch() {
|
||||
return useContext(SearchContext);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import { useVoice } from '../contexts/VoiceContext';
|
||||
import FriendsView from '../components/FriendsView';
|
||||
import MembersList from '../components/MembersList';
|
||||
import ChatHeader from '../components/ChatHeader';
|
||||
import SearchPanel from '../components/SearchPanel';
|
||||
import SearchDropdown from '../components/SearchDropdown';
|
||||
import { useToasts } from '../components/Toast';
|
||||
import { PresenceProvider } from '../contexts/PresenceContext';
|
||||
import { getUserPref, setUserPref } from '../utils/userPreferences';
|
||||
import { usePlatform } from '../platform';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
const MAX_SEARCH_HISTORY = 10;
|
||||
|
||||
const Chat = () => {
|
||||
const { crypto, settings } = usePlatform();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -31,6 +35,17 @@ const Chat = () => {
|
||||
const [showPinned, setShowPinned] = useState(false);
|
||||
const [mobileView, setMobileView] = useState('sidebar');
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSearchDropdown, setShowSearchDropdown] = useState(false);
|
||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||
const [searchSortOrder, setSearchSortOrder] = useState('newest');
|
||||
const [searchHistory, setSearchHistory] = useState(() => {
|
||||
const id = localStorage.getItem('userId');
|
||||
return id ? getUserPref(id, 'searchHistory', []) : [];
|
||||
});
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const convex = useConvex();
|
||||
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
|
||||
const prevDmChannelsRef = useRef(null);
|
||||
@@ -41,7 +56,11 @@ const Chat = () => {
|
||||
const handler = (e) => {
|
||||
if (e.ctrlKey && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
// Quick switcher placeholder - could open a search modal
|
||||
// Focus the search input
|
||||
const input = searchInputRef.current?.querySelector('input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
|
||||
e.preventDefault();
|
||||
@@ -57,6 +76,7 @@ const Chat = () => {
|
||||
const serverSettings = useQuery(api.serverSettings.get);
|
||||
const serverName = serverSettings?.serverName || 'Secure Chat';
|
||||
const serverIconUrl = serverSettings?.iconUrl || null;
|
||||
const allMembers = useQuery(api.members.listAll) || [];
|
||||
|
||||
const rawChannelKeys = useQuery(
|
||||
api.channelKeys.getKeysForUser,
|
||||
@@ -195,6 +215,127 @@ const Chat = () => {
|
||||
}
|
||||
}, [voiceActiveChannelId]);
|
||||
|
||||
// Search handlers
|
||||
const handleSearchQueryChange = useCallback((val) => {
|
||||
setSearchQuery(val);
|
||||
if (val === '') {
|
||||
setShowSearchResults(false);
|
||||
}
|
||||
if (!showSearchDropdown && val !== undefined) {
|
||||
setShowSearchDropdown(true);
|
||||
}
|
||||
}, [showSearchDropdown]);
|
||||
|
||||
const handleSearchFocus = useCallback(() => {
|
||||
setShowSearchDropdown(true);
|
||||
}, []);
|
||||
|
||||
const handleSearchBlur = useCallback(() => {
|
||||
// Dropdown close is handled by click-outside in SearchDropdown
|
||||
}, []);
|
||||
|
||||
const handleSearchSubmit = useCallback(() => {
|
||||
if (!searchQuery.trim()) return;
|
||||
setShowSearchDropdown(false);
|
||||
setShowSearchResults(true);
|
||||
// Save to history
|
||||
setSearchHistory(prev => {
|
||||
const filtered = prev.filter(h => h !== searchQuery.trim());
|
||||
const updated = [searchQuery.trim(), ...filtered].slice(0, MAX_SEARCH_HISTORY);
|
||||
if (userId) {
|
||||
setUserPref(userId, 'searchHistory', updated, settings);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, [searchQuery, userId, settings]);
|
||||
|
||||
const handleSelectFilter = useCallback((prefix, value) => {
|
||||
if (value !== null) {
|
||||
// Replace the current active prefix with the completed token
|
||||
const beforePrefix = searchQuery.replace(/\b(from|in|has|mentions):\S*$/i, '').trimEnd();
|
||||
const newQuery = beforePrefix + (beforePrefix ? ' ' : '') + prefix + ':' + value + ' ';
|
||||
setSearchQuery(newQuery);
|
||||
} else {
|
||||
// Just insert the prefix (e.g., clicking "from:" suggestion)
|
||||
const newQuery = searchQuery + (searchQuery && !searchQuery.endsWith(' ') ? ' ' : '') + prefix + ':';
|
||||
setSearchQuery(newQuery);
|
||||
}
|
||||
// Re-focus input
|
||||
setTimeout(() => {
|
||||
const input = searchInputRef.current?.querySelector('input');
|
||||
if (input) input.focus();
|
||||
}, 0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSelectHistoryItem = useCallback((item) => {
|
||||
setSearchQuery(item);
|
||||
setShowSearchDropdown(false);
|
||||
setShowSearchResults(true);
|
||||
}, []);
|
||||
|
||||
const handleClearHistory = useCallback(() => {
|
||||
setSearchHistory([]);
|
||||
if (userId) {
|
||||
setUserPref(userId, 'searchHistory', [], settings);
|
||||
}
|
||||
}, [userId, settings]);
|
||||
|
||||
const handleClearHistoryItem = useCallback((index) => {
|
||||
setSearchHistory(prev => {
|
||||
const updated = prev.filter((_, i) => i !== index);
|
||||
if (userId) {
|
||||
setUserPref(userId, 'searchHistory', updated, settings);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, [userId, settings]);
|
||||
|
||||
const handleCloseSearchDropdown = useCallback(() => {
|
||||
setShowSearchDropdown(false);
|
||||
}, []);
|
||||
|
||||
const handleCloseSearchResults = useCallback(() => {
|
||||
setShowSearchResults(false);
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
const handleJumpToMessage = useCallback((channelId, messageId) => {
|
||||
// Switch to the correct channel if needed
|
||||
const isDM = dmChannels.some(dm => dm.channel_id === channelId);
|
||||
if (isDM) {
|
||||
const dm = dmChannels.find(d => d.channel_id === channelId);
|
||||
if (dm) {
|
||||
setActiveDMChannel(dm);
|
||||
setView('me');
|
||||
}
|
||||
} else {
|
||||
setActiveChannel(channelId);
|
||||
setView('server');
|
||||
}
|
||||
setShowSearchResults(false);
|
||||
setSearchQuery('');
|
||||
// Give time for channel to render then scroll
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(`msg-${messageId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('message-highlight');
|
||||
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
||||
}
|
||||
}, 300);
|
||||
}, [dmChannels]);
|
||||
|
||||
// Shared search props for ChatHeader
|
||||
const searchProps = {
|
||||
searchQuery,
|
||||
onSearchQueryChange: handleSearchQueryChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchFocus: handleSearchFocus,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
searchInputRef,
|
||||
searchActive: showSearchDropdown || showSearchResults,
|
||||
};
|
||||
|
||||
function renderMainContent() {
|
||||
if (view === 'me') {
|
||||
if (activeDMChannel) {
|
||||
@@ -208,6 +349,7 @@ const Chat = () => {
|
||||
onTogglePinned={() => setShowPinned(p => !p)}
|
||||
isMobile={isMobile}
|
||||
onMobileBack={handleMobileBack}
|
||||
{...searchProps}
|
||||
/>
|
||||
<div className="chat-content">
|
||||
<ChatArea
|
||||
@@ -223,6 +365,17 @@ const Chat = () => {
|
||||
showPinned={showPinned}
|
||||
onTogglePinned={() => setShowPinned(false)}
|
||||
/>
|
||||
<SearchPanel
|
||||
visible={showSearchResults}
|
||||
onClose={handleCloseSearchResults}
|
||||
channels={channels}
|
||||
isDM={true}
|
||||
dmChannelId={activeDMChannel.channel_id}
|
||||
onJumpToMessage={handleJumpToMessage}
|
||||
query={searchQuery}
|
||||
sortOrder={searchSortOrder}
|
||||
onSortChange={setSearchSortOrder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -277,6 +430,7 @@ const Chat = () => {
|
||||
serverName={serverName}
|
||||
isMobile={isMobile}
|
||||
onMobileBack={handleMobileBack}
|
||||
{...searchProps}
|
||||
/>
|
||||
<div className="chat-content">
|
||||
<ChatArea
|
||||
@@ -297,6 +451,15 @@ const Chat = () => {
|
||||
visible={effectiveShowMembers}
|
||||
onMemberClick={(member) => {}}
|
||||
/>
|
||||
<SearchPanel
|
||||
visible={showSearchResults}
|
||||
onClose={handleCloseSearchResults}
|
||||
channels={channels}
|
||||
onJumpToMessage={handleJumpToMessage}
|
||||
query={searchQuery}
|
||||
sortOrder={searchSortOrder}
|
||||
onSortChange={setSearchSortOrder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -359,6 +522,21 @@ const Chat = () => {
|
||||
)}
|
||||
{showMainContent && renderMainContent()}
|
||||
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
|
||||
{showSearchDropdown && !isMobile && (
|
||||
<SearchDropdown
|
||||
visible={showSearchDropdown}
|
||||
searchText={searchQuery}
|
||||
channels={channels}
|
||||
members={allMembers}
|
||||
searchHistory={searchHistory}
|
||||
onSelectFilter={handleSelectFilter}
|
||||
onSelectHistoryItem={handleSelectHistoryItem}
|
||||
onClearHistory={handleClearHistory}
|
||||
onClearHistoryItem={handleClearHistoryItem}
|
||||
anchorRef={searchInputRef}
|
||||
onClose={handleCloseSearchDropdown}
|
||||
/>
|
||||
)}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</PresenceProvider>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,11 +57,24 @@
|
||||
* @property {() => Promise<object>} checkUpdate
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformSearchDB
|
||||
* @property {(dbKeyHex: string, userId: string) => Promise<void>} open
|
||||
* @property {() => Promise<void>} close
|
||||
* @property {() => Promise<void>} save
|
||||
* @property {(messages: Array) => void} indexMessages
|
||||
* @property {(params: object) => Array} search
|
||||
* @property {(messageId: string) => boolean} isIndexed
|
||||
* @property {() => boolean} isOpen
|
||||
* @property {() => object} getStats
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformFeatures
|
||||
* @property {boolean} hasWindowControls
|
||||
* @property {boolean} hasScreenCapture
|
||||
* @property {boolean} hasNativeUpdates
|
||||
* @property {boolean} hasSearch
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -74,6 +87,7 @@
|
||||
* @property {PlatformScreenCapture|null} screenCapture
|
||||
* @property {PlatformWindowControls|null} windowControls
|
||||
* @property {PlatformUpdates|null} updates
|
||||
* @property {PlatformSearchDB|null} searchDB
|
||||
* @property {PlatformFeatures} features
|
||||
*/
|
||||
|
||||
|
||||
389
packages/shared/src/utils/SearchDatabase.js
Normal file
389
packages/shared/src/utils/SearchDatabase.js
Normal file
@@ -0,0 +1,389 @@
|
||||
import initSqlJsModule from 'sql.js';
|
||||
const initSqlJs = initSqlJsModule.default || initSqlJsModule;
|
||||
import wasmUrl from 'sql.js/dist/sql-wasm.wasm?url';
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>]+/i;
|
||||
const MENTION_RE = /@(\w+)/g;
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
sender_id TEXT,
|
||||
username TEXT,
|
||||
content TEXT,
|
||||
created_at INTEGER,
|
||||
has_attachment INTEGER DEFAULT 0,
|
||||
has_link INTEGER DEFAULT 0,
|
||||
has_mention INTEGER DEFAULT 0,
|
||||
mentioned_users TEXT,
|
||||
attachment_filename TEXT,
|
||||
attachment_type TEXT,
|
||||
attachment_meta TEXT DEFAULT '',
|
||||
pinned INTEGER DEFAULT 0,
|
||||
reply_to_id TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel ON messages(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sender ON messages(sender_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_created ON messages(created_at);
|
||||
`;
|
||||
|
||||
let sqlPromise = null;
|
||||
|
||||
function getSql() {
|
||||
if (!sqlPromise) {
|
||||
sqlPromise = initSqlJs({ locateFile: () => wasmUrl });
|
||||
}
|
||||
return sqlPromise;
|
||||
}
|
||||
|
||||
export default class SearchDatabase {
|
||||
constructor(storageAdapter, cryptoAdapter) {
|
||||
this.storage = storageAdapter;
|
||||
this.crypto = cryptoAdapter;
|
||||
this.db = null;
|
||||
this.dbKey = null;
|
||||
this._dirty = false;
|
||||
this._saveTimer = null;
|
||||
this._opened = false;
|
||||
this._userId = null;
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return this._opened && this.db !== null;
|
||||
}
|
||||
|
||||
async open(dbKeyHex, userId) {
|
||||
if (this._opened) return;
|
||||
this.dbKey = dbKeyHex;
|
||||
this._userId = userId;
|
||||
|
||||
const SQL = await getSql();
|
||||
|
||||
// Try loading from storage
|
||||
let loaded = false;
|
||||
try {
|
||||
const blob = await this.storage.load(userId);
|
||||
if (blob && blob.length > 0) {
|
||||
const json = new TextDecoder().decode(blob);
|
||||
const { content, iv, tag } = JSON.parse(json);
|
||||
const decrypted = await this.crypto.decryptData(content, dbKeyHex, iv, tag);
|
||||
const bytes = hexToBytes(decrypted);
|
||||
this.db = new SQL.Database(bytes);
|
||||
loaded = true;
|
||||
// Drop old FTS5 artifacts from previous schema
|
||||
try {
|
||||
this.db.run('DROP TRIGGER IF EXISTS messages_ai');
|
||||
this.db.run('DROP TRIGGER IF EXISTS messages_ad');
|
||||
this.db.run('DROP TRIGGER IF EXISTS messages_au');
|
||||
} catch {}
|
||||
try { this.db.run('DROP TABLE IF EXISTS messages_fts'); } catch {}
|
||||
// Migrate: add attachment_meta column if missing
|
||||
try { this.db.run("ALTER TABLE messages ADD COLUMN attachment_meta TEXT DEFAULT ''"); } catch {}
|
||||
console.log('Search DB loaded from encrypted storage');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Search DB decrypt failed, starting fresh:', err.message);
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
this.db = new SQL.Database();
|
||||
this.db.run(SCHEMA_SQL);
|
||||
console.log('Search DB created fresh');
|
||||
}
|
||||
|
||||
this._opened = true;
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this._opened) return;
|
||||
if (this._saveTimer) {
|
||||
clearTimeout(this._saveTimer);
|
||||
this._saveTimer = null;
|
||||
}
|
||||
if (this._dirty) {
|
||||
await this.save();
|
||||
}
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
this.dbKey = null;
|
||||
this._opened = false;
|
||||
this._userId = null;
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (!this.db || !this.dbKey || !this._userId) return;
|
||||
try {
|
||||
const data = this.db.export();
|
||||
const hex = bytesToHex(data);
|
||||
const encrypted = await this.crypto.encryptData(hex, this.dbKey);
|
||||
const json = JSON.stringify({ content: encrypted.content, iv: encrypted.iv, tag: encrypted.tag });
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
await this.storage.save(this._userId, bytes);
|
||||
this._dirty = false;
|
||||
console.log('Search DB saved');
|
||||
} catch (err) {
|
||||
console.error('Search DB save error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleSave() {
|
||||
if (this._saveTimer) return;
|
||||
this._saveTimer = setTimeout(() => {
|
||||
this._saveTimer = null;
|
||||
this.save();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
indexMessages(messages) {
|
||||
if (!this.db || messages.length === 0) return;
|
||||
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
try {
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT OR REPLACE INTO messages (id, channel_id, sender_id, username, content, created_at, has_attachment, has_link, has_mention, mentioned_users, attachment_filename, attachment_type, attachment_meta, pinned, reply_to_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
for (const msg of messages) {
|
||||
let content = msg.content || '';
|
||||
let hasAttachment = 0;
|
||||
let hasLink = 0;
|
||||
let hasMention = 0;
|
||||
let mentionedUsers = '';
|
||||
let attachmentFilename = '';
|
||||
let attachmentType = '';
|
||||
let attachmentMeta = '';
|
||||
|
||||
// Parse attachment
|
||||
try {
|
||||
if (content.startsWith('{')) {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed.type === 'attachment') {
|
||||
hasAttachment = 1;
|
||||
attachmentFilename = parsed.filename || '';
|
||||
attachmentType = parsed.mimeType || '';
|
||||
attachmentMeta = content;
|
||||
content = `[File: ${attachmentFilename}]`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Check for links
|
||||
if (URL_RE.test(content)) hasLink = 1;
|
||||
|
||||
// Check for mentions
|
||||
const mentions = [];
|
||||
let m;
|
||||
while ((m = MENTION_RE.exec(content)) !== null) {
|
||||
mentions.push(m[1]);
|
||||
}
|
||||
if (mentions.length > 0) {
|
||||
hasMention = 1;
|
||||
mentionedUsers = mentions.join(',');
|
||||
}
|
||||
|
||||
const createdAt = typeof msg.created_at === 'number'
|
||||
? msg.created_at
|
||||
: new Date(msg.created_at).getTime();
|
||||
|
||||
stmt.run([
|
||||
msg.id,
|
||||
msg.channel_id,
|
||||
msg.sender_id || null,
|
||||
msg.username || null,
|
||||
content,
|
||||
createdAt,
|
||||
hasAttachment,
|
||||
hasLink,
|
||||
hasMention,
|
||||
mentionedUsers || null,
|
||||
attachmentFilename || null,
|
||||
attachmentType || null,
|
||||
attachmentMeta || null,
|
||||
msg.pinned ? 1 : 0,
|
||||
msg.replyToId || null,
|
||||
]);
|
||||
}
|
||||
|
||||
stmt.free();
|
||||
this.db.run('COMMIT');
|
||||
this._dirty = true;
|
||||
this._scheduleSave();
|
||||
} catch (err) {
|
||||
try { this.db.run('ROLLBACK'); } catch {}
|
||||
console.error('Search DB indexing error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
isIndexed(messageId) {
|
||||
if (!this.db) return false;
|
||||
try {
|
||||
const stmt = this.db.prepare('SELECT 1 FROM messages WHERE id = ?');
|
||||
stmt.bind([messageId]);
|
||||
const found = stmt.step();
|
||||
stmt.free();
|
||||
return found;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
search({ query, channelId, senderId, senderName, hasLink, hasAttachment, hasImage, hasVideo, hasFile, hasMention, before, after, pinned, limit = 50, offset = 0 }) {
|
||||
if (!this.db) return [];
|
||||
|
||||
try {
|
||||
let sql, params = [];
|
||||
const conditions = [];
|
||||
let queryWords = [];
|
||||
|
||||
if (query && query.trim()) {
|
||||
queryWords = query.trim().split(/\s+/).filter(Boolean);
|
||||
sql = `SELECT m.* FROM messages m WHERE 1=1`;
|
||||
for (const word of queryWords) {
|
||||
conditions.push('(m.content LIKE ? OR m.username LIKE ? OR m.attachment_filename LIKE ?)');
|
||||
const pattern = `%${word}%`;
|
||||
params.push(pattern, pattern, pattern);
|
||||
}
|
||||
} else {
|
||||
sql = `SELECT m.* FROM messages m WHERE 1=1`;
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
conditions.push('m.channel_id = ?');
|
||||
params.push(channelId);
|
||||
}
|
||||
if (senderId) {
|
||||
conditions.push('m.sender_id = ?');
|
||||
params.push(senderId);
|
||||
}
|
||||
if (senderName) {
|
||||
conditions.push('m.username = ?');
|
||||
params.push(senderName);
|
||||
}
|
||||
if (hasLink) {
|
||||
conditions.push('m.has_link = 1');
|
||||
}
|
||||
if (hasAttachment) {
|
||||
conditions.push('m.has_attachment = 1');
|
||||
}
|
||||
if (hasImage) {
|
||||
conditions.push("m.has_attachment = 1 AND m.attachment_type LIKE 'image/%'");
|
||||
}
|
||||
if (hasVideo) {
|
||||
conditions.push("m.has_attachment = 1 AND m.attachment_type LIKE 'video/%'");
|
||||
}
|
||||
if (hasFile) {
|
||||
conditions.push("m.has_attachment = 1 AND m.attachment_type NOT LIKE 'image/%' AND m.attachment_type NOT LIKE 'video/%'");
|
||||
}
|
||||
if (hasMention) {
|
||||
conditions.push('m.has_mention = 1');
|
||||
}
|
||||
if (before) {
|
||||
conditions.push('m.created_at < ?');
|
||||
params.push(new Date(before).getTime());
|
||||
}
|
||||
if (after) {
|
||||
conditions.push('m.created_at > ?');
|
||||
params.push(new Date(after).getTime());
|
||||
}
|
||||
if (pinned) {
|
||||
conditions.push('m.pinned = 1');
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += ' AND ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
sql += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
|
||||
const stmt = this.db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
|
||||
const results = [];
|
||||
while (stmt.step()) {
|
||||
const row = stmt.getAsObject();
|
||||
results.push({
|
||||
id: row.id,
|
||||
channel_id: row.channel_id,
|
||||
sender_id: row.sender_id,
|
||||
username: row.username,
|
||||
content: row.content,
|
||||
created_at: row.created_at,
|
||||
has_attachment: !!row.has_attachment,
|
||||
has_link: !!row.has_link,
|
||||
pinned: !!row.pinned,
|
||||
attachment_type: row.attachment_type || '',
|
||||
attachment_meta: row.attachment_meta || '',
|
||||
snippet: queryWords.length > 0
|
||||
? generateSnippet(row.content || '', queryWords)
|
||||
: row.content,
|
||||
reply_to_id: row.reply_to_id,
|
||||
});
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error('Search DB query error:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
if (!this.db) return { count: 0 };
|
||||
try {
|
||||
const result = this.db.exec('SELECT COUNT(*) as cnt FROM messages');
|
||||
return { count: result[0]?.values[0]?.[0] || 0 };
|
||||
} catch {
|
||||
return { count: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateSnippet(content, queryWords) {
|
||||
if (!content) return '';
|
||||
const lower = content.toLowerCase();
|
||||
// Find earliest match position
|
||||
let firstIdx = content.length;
|
||||
for (const word of queryWords) {
|
||||
const idx = lower.indexOf(word.toLowerCase());
|
||||
if (idx !== -1 && idx < firstIdx) firstIdx = idx;
|
||||
}
|
||||
if (firstIdx === content.length) firstIdx = 0;
|
||||
|
||||
// Extract ~80 chars of context around the match
|
||||
const start = Math.max(0, firstIdx - 40);
|
||||
const end = Math.min(content.length, firstIdx + 40);
|
||||
let slice = content.slice(start, end);
|
||||
if (start > 0) slice = '...' + slice;
|
||||
if (end < content.length) slice = slice + '...';
|
||||
|
||||
// Escape HTML then wrap matches in <mark>
|
||||
slice = slice.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
for (const word of queryWords) {
|
||||
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
slice = slice.replace(new RegExp(escaped, 'gi'), m => `<mark>${m}</mark>`);
|
||||
}
|
||||
return slice;
|
||||
}
|
||||
|
||||
function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
let hex = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
hex += bytes[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
47
packages/shared/src/utils/searchUtils.js
Normal file
47
packages/shared/src/utils/searchUtils.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const FILTER_RE = /\b(from|has|mentions|before|after|in|pinned):(\S+)/gi;
|
||||
|
||||
export function parseFilters(rawQuery) {
|
||||
const filters = {};
|
||||
let textQuery = rawQuery;
|
||||
|
||||
let match;
|
||||
while ((match = FILTER_RE.exec(rawQuery)) !== null) {
|
||||
const key = match[1].toLowerCase();
|
||||
const val = match[2];
|
||||
switch (key) {
|
||||
case 'from': filters.senderName = val; break;
|
||||
case 'has':
|
||||
if (val === 'link') filters.hasLink = true;
|
||||
else if (val === 'file' || val === 'attachment') filters.hasFile = true;
|
||||
else if (val === 'image') filters.hasImage = true;
|
||||
else if (val === 'video') filters.hasVideo = true;
|
||||
else if (val === 'mention') filters.hasMention = true;
|
||||
break;
|
||||
case 'mentions': filters.hasMention = true; filters.mentionName = val; break;
|
||||
case 'before': filters.before = val; break;
|
||||
case 'after': filters.after = val; break;
|
||||
case 'in': filters.channelName = val; break;
|
||||
case 'pinned': filters.pinned = val === 'true' || val === 'yes'; break;
|
||||
}
|
||||
textQuery = textQuery.replace(match[0], '');
|
||||
}
|
||||
FILTER_RE.lastIndex = 0;
|
||||
|
||||
return { textQuery: textQuery.trim(), filters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the user is mid-typing a filter token.
|
||||
* e.g. "hello from:par" → { prefix: 'from', partial: 'par' }
|
||||
* e.g. "from:" → { prefix: 'from', partial: '' }
|
||||
* Returns null if no active prefix is detected at the end of text.
|
||||
*/
|
||||
export function detectActivePrefix(text) {
|
||||
if (!text) return null;
|
||||
// Match a filter prefix at the end of the string, possibly with a partial value
|
||||
const m = text.match(/\b(from|in|has|mentions):(\S*)$/i);
|
||||
if (m) {
|
||||
return { prefix: m[1].toLowerCase(), partial: m[2].toLowerCase() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user