Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/qc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url),
// mcp); exit then, or the SSE socket and timers would hold the process open,
// but only once stdout has drained (see exit.js).
main(process.argv.slice(2), { version: pkg.version }).then(
() => exitWhenFlushed(0),
() => exitWhenFlushed(process.exitCode ?? 0),
(err) => {
process.stderr.write(`${errorLine(err)}\n`);
exitWhenFlushed(1);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.8.1",
"version": "0.9.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/bin/qc.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { errorLine, exitWhenFlushed, main } from '../dist/commands.js';
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));

main(process.argv.slice(2), { version: pkg.version }).then(
() => exitWhenFlushed(0),
() => exitWhenFlushed(process.exitCode ?? 0),
(err) => {
process.stderr.write(`${errorLine(err)}\n`);
exitWhenFlushed(1);
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/qryptchat",
"version": "0.7.0",
"version": "0.8.0",
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
"type": "module",
"bin": {
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/chat/conversations/[id]/participants/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { supabaseAuthCookieName } from '@/lib/supabase/auth-cookie.js';
import { createServiceRoleClient } from '@/lib/supabase/service-role.js';
import { bearerToken } from '@/lib/auth/bearer.js';
import { GroupInviteError, addMembers, requireGroupAdmin } from '@/lib/chat/group-invites.js';
import { announceJoined } from '@/lib/chat/membership-events.js';
import { sseManager } from '@/lib/api/sse-manager.js';

// Lazy service role client creation
let supabaseServiceRole = null;
Expand Down Expand Up @@ -210,6 +212,7 @@ export async function POST(request, { params } = {}) {
const db = getServiceRoleClient();
await requireGroupAdmin(db, conversationId, user.id);
const added = await addMembers(db, conversationId, body.participant_ids);
announceJoined(sseManager, conversationId, added);
return NextResponse.json({ success: true, added });
} catch (error) {
if (error instanceof GroupInviteError) return NextResponse.json({ error: error.message, code: error.code }, { status: error.status });
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/conversations/archive/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/middleware/auth.js';
import { sseManager } from '@/lib/api/sse-manager.js';
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
import { appUserId } from '@/lib/chat/group-invites.js';
import { announceArchived } from '@/lib/chat/membership-events.js';

function normalizeConversationId(conversationId) {
return typeof conversationId === 'string' ? conversationId.trim() : '';
Expand Down Expand Up @@ -39,6 +43,12 @@ export const POST = withAuth(async ({ request, locals }) => {
}, { status: 404 });
}

// Every device of this user moves the chat between its lists.
// Announcing never turns a done archive into an error.
try {
announceArchived(sseManager, await appUserId(getServiceRoleClient(), authUser.id), conversationId, true);
} catch {}

return NextResponse.json({ success: true });
} catch (error) {
console.error('Archive conversation error:', error);
Expand Down
23 changes: 22 additions & 1 deletion src/app/api/conversations/archive/route.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
rpc: vi.fn()
rpc: vi.fn(),
sendToUser: vi.fn(),
appUserId: vi.fn()
}));

vi.mock('@/lib/api/sse-manager.js', () => ({ sseManager: { sendToUser: mocks.sendToUser } }));
vi.mock('@/lib/supabase/service-role.js', () => ({ getServiceRoleClient: () => ({}) }));
vi.mock('@/lib/chat/group-invites.js', () => ({ appUserId: mocks.appUserId }));

vi.mock('@/lib/api/middleware/auth.js', () => ({
withAuth: (handler) => (request, context) =>
handler({
Expand All @@ -29,6 +35,21 @@ describe('conversation archive routes', () => {
vi.resetModules();
vi.clearAllMocks();
mocks.rpc.mockResolvedValue({ data: true, error: null });
mocks.appUserId.mockResolvedValue('user-1');
});

it('tells every device of the user that the chat was archived', async () => {
const { POST } = await import('./route.js');
const response = await POST(createRequest('conversation-1'));
expect(response.status).toBe(200);
expect(mocks.sendToUser).toHaveBeenCalledWith('user-1', 'CONVERSATION_ARCHIVED', expect.objectContaining({ conversationId: 'conversation-1', archived: true }));
});

it('still succeeds when the announcement cannot be made', async () => {
mocks.appUserId.mockRejectedValue(new Error('no db'));
const { POST } = await import('./route.js');
const response = await POST(createRequest('conversation-1'));
expect(response.status).toBe(200);
});

it('trims conversation ids before archiving', async () => {
Expand Down
3 changes: 3 additions & 0 deletions src/app/api/conversations/delete/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/middleware/auth.js';
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
import { sseManager } from '@/lib/api/sse-manager.js';
import { announceLeft } from '@/lib/chat/membership-events.js';

export const POST = withAuth(async ({ request, locals }) => {
try {
Expand Down Expand Up @@ -195,6 +197,7 @@ export const POST = withAuth(async ({ request, locals }) => {
console.log(`✅ Garbage-collected orphaned conversation ${conversationId}`);
}

announceLeft(sseManager, conversationId, internalUserId);
return NextResponse.json({ success: true, conversationRemoved: isOrphaned });
} catch (error) {
console.error('Delete conversation error:', error);
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/conversations/join/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { withAuth } from '@/lib/api/middleware/auth.js';
import { sseManager } from '@/lib/api/sse-manager.js';
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
import { GroupInviteError, appUserId, parseInviteCode, redeemInvite } from '@/lib/chat/group-invites.js';
import { announceJoined } from '@/lib/chat/membership-events.js';

function normalizeConversationId(conversationId) {
return typeof conversationId === 'string' ? conversationId.trim() : '';
Expand All @@ -22,6 +23,7 @@ async function joinByInvite(input, authUserId) {
const userId = await appUserId(db, authUserId);
const result = await redeemInvite(db, code, userId);
sseManager.joinRoom(userId, result.conversation_id);
if (result.joined) announceJoined(sseManager, result.conversation_id, [userId]);
return NextResponse.json({ success: true, ...result });
} catch (error) {
if (error instanceof GroupInviteError) return NextResponse.json({ error: error.message, code: error.code }, { status: error.status });
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/conversations/unarchive/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/middleware/auth.js';
import { sseManager } from '@/lib/api/sse-manager.js';
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
import { appUserId } from '@/lib/chat/group-invites.js';
import { announceArchived } from '@/lib/chat/membership-events.js';

function normalizeConversationId(conversationId) {
return typeof conversationId === 'string' ? conversationId.trim() : '';
Expand Down Expand Up @@ -39,6 +43,12 @@ export const POST = withAuth(async ({ request, locals }) => {
}, { status: 404 });
}

// Every device of this user moves the chat between its lists.
// Announcing never turns a done archive into an error.
try {
announceArchived(sseManager, await appUserId(getServiceRoleClient(), authUser.id), conversationId, false);
} catch {}

return NextResponse.json({ success: true });
} catch (error) {
console.error('Unarchive conversation error:', error);
Expand Down
5 changes: 5 additions & 0 deletions src/cli/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ export class QcClient {
return res.text();
}

/** Archive a chat for you (or bring it back with on = false); the others still see it. */
async archive(conversationId, on = true) {
return this.request(`/api/conversations/${on ? 'archive' : 'unarchive'}`, { method: 'POST', body: { conversationId } });
}

/** Conversations, newest activity first, each with a display title. */
async conversations() {
const { conversations = [] } = await this.request('/api/conversations/load', { method: 'POST', body: {} });
Expand Down
46 changes: 44 additions & 2 deletions src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { joinAsAgent } from './agent.js';
import { parseInviteCode } from '../lib/chat/group-invites.js';
import { em, fail, info, mark, ok, warn, who } from './style.js';
import { notify } from './notify.js';
import { parseVoiceArgs, runPairux, voiceLink, VOICE_HELP } from './voice.js';
import { voiceMessage } from '../lib/chat/voice.js';
export { exitWhenFlushed } from './exit.js';

/** An error as qc prints it: the error icon and the message. */
Expand All @@ -27,7 +29,10 @@ Usage:
qc profile [--emoji 🔭] [--pronouns she/her] [--website URL] [--bio TEXT]
show or set your public profile (an empty value clears it);
--openprofile prints it as OpenProfile.md
qc chats list your chats
qc chats [--archived] list your chats (archived ones only with --archived)
qc archive <chat> archive a chat for you (qc unarchive <chat> brings it back)
qc voice [--send <chat>] a link to a fresh end-to-end encrypted PairUX voice room (no account
needed to join); qc voice login|join|... runs the pairux CLI (qc voice help)
qc read <chat> [-n 20] print the last messages of a chat
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
qc react <chat> <message-id> <emoji> [--remove] react to a message
Expand Down Expand Up @@ -109,6 +114,9 @@ export async function main(argv, { version = '0.0.0' } = {}) {
const out = (line) => process.stdout.write(`${line}\n`);
const json = (value) => out(JSON.stringify(value, null, flags.json === 'compact' ? 0 : 2));

// qc voice is the pairux CLI: its arguments are pairux's, not qc's.
if (cmd === 'voice') return voiceCommand(argv.slice(argv.indexOf('voice') + 1), { out, json });

if (flags.version) return out(`qc ${version}`);
if (flags.help || cmd === 'help') return out(HELP);
if (flags.url) process.env.QC_URL = flags.url;
Expand Down Expand Up @@ -191,11 +199,21 @@ export async function main(argv, { version = '0.0.0' } = {}) {
}
case 'chats':
case 'ls': {
const chats = await (await client(flags)).conversations();
const all = await (await client(flags)).conversations();
const chats = all.filter((c) => !!c.is_archived === !!flags.archived);
if (flags.json) return json(chats.map((c) => ({ id: c.id, title: c.title, type: c.type, updated_at: c.updated_at, participants: c.participants?.length ?? 0 })));
for (const c of chats) out(`${c.id} ${mark(c.type === 'group' ? 'users' : 'chat')}${c.title}`);
return;
}
case 'archive':
case 'unarchive': {
if (!rest[0]) throw new Error(`Usage: qc ${cmd} <chat>`);
const c = await client(flags);
const chat = findChat(await c.conversations(), rest[0]);
await c.archive(chat.id, cmd === 'archive');
if (flags.json) return json({ id: chat.id, archived: cmd === 'archive' });
return out(ok(cmd === 'archive' ? `Archived ${chat.title}. qc chats --archived lists it; qc unarchive brings it back.` : `${chat.title} is back in your chats.`));
}
case 'read': {
if (!rest[0]) throw new Error('Usage: qc read <chat> [-n 20]');
const c = await client(flags);
Expand Down Expand Up @@ -290,3 +308,27 @@ export async function main(argv, { version = '0.0.0' } = {}) {
throw new Error(`Unknown command "${cmd}".\n\n${HELP}`);
}
}

async function voiceCommand(argv, { out, json }) {
const parsed = parseVoiceArgs(argv);
if (parsed.action === 'help') return out(VOICE_HELP);
if (parsed.action === 'pairux') {
const code = await runPairux(parsed.args);
if (code) process.exitCode = code;
return;
}
const url = voiceLink();
let sent = null;
if (parsed.send) {
const c = await client({});
const chat = findChat(await c.conversations(), parsed.send);
const { skipped } = await c.send(chat.id, voiceMessage(url));
sent = { id: chat.id, title: chat.title, skipped };
}
if (parsed.json) return json({ url, sent });
out(url);
if (sent) {
if (sent.skipped) process.stderr.write(`qc: ${warn(`${sent.skipped} participant(s) have no key yet and will not see it.`)}\n`);
process.stderr.write(`qc: ${mark('send')}Sent to ${sent.title}.\n`);
}
}
Loading
Loading