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 package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.9.3",
"version": "0.9.4",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
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.8.0",
"version": "0.8.1",
"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
2 changes: 1 addition & 1 deletion src/app/api/conversations/load/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const POST = withAuth(async ({ locals }) => {
participants:conversation_participants(
user_id,
role,
user:users!conversation_participants_user_id_fkey(id, username, display_name, avatar_url, is_online, last_seen)
user:users!conversation_participants_user_id_fkey(id, username, display_name, avatar_url, is_online, last_seen, account_type)
)
`)
.in('id', conversationIds)
Expand Down
14 changes: 13 additions & 1 deletion src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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';
import { namesLines } from '../lib/chat/irc.js';
export { exitWhenFlushed } from './exit.js';

/** An error as qc prints it: the error icon and the message. */
Expand All @@ -29,7 +30,8 @@ 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 [--archived] list your chats (archived ones only with --archived)
qc chats [--archived] list your chats (archived ones only with --archived); qc list is the same
qc names <chat> who is in a chat (@ = admin, 🤖 = agent), like IRC /names
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)
Expand Down Expand Up @@ -198,6 +200,7 @@ export async function main(argv, { version = '0.0.0' } = {}) {
return;
}
case 'chats':
case 'list':
case 'ls': {
const all = await (await client(flags)).conversations();
const chats = all.filter((c) => !!c.is_archived === !!flags.archived);
Expand All @@ -214,6 +217,15 @@ export async function main(argv, { version = '0.0.0' } = {}) {
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 'names': {
if (!rest[0]) throw new Error('Usage: qc names <chat>');
const chat = findChat(await (await client(flags)).conversations(), rest[0]);
if (flags.json) {
return json((chat.participants || []).map((p) => ({ id: p.user_id, username: p.user?.username, display_name: p.user?.display_name, role: p.role, agent: p.user?.account_type === 'agent' })));
}
for (const line of namesLines(chat, chat.title)) out(line);
return;
}
case 'read': {
if (!rest[0]) throw new Error('Usage: qc read <chat> [-n 20]');
const c = await client(flags);
Expand Down
17 changes: 15 additions & 2 deletions src/cli/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* Composer commands: /voice posts a fresh end-to-end encrypted PairUX room,
* /archive and /unarchive file the open chat away, /archived flips the list
* between your chats and the archived ones (also Ctrl+O; `a` in the list).
* /names (who is here) and /list (your chats) answer IRC-style in the
* transcript, shown only to you and never sent.
* The chat list reloads every 15 s as well as on live events, so it never
* goes stale if the events stream drops.
*
Expand All @@ -28,6 +30,7 @@ import { join } from 'node:path';
import { configDir } from './config.js';
import { notify } from './notify.js';
import { newVoiceRoomLink, parseSlashCommand, PAIRUX_URL, voiceMessage } from '../lib/chat/voice.js';
import { listLines, namesLines } from '../lib/chat/irc.js';
import { CHAT_LIST_EVENTS, MEMBERSHIP_EVENTS } from '../lib/chat/membership-events.js';

const SIDEBAR = 30;
Expand Down Expand Up @@ -66,7 +69,7 @@ export function chatsWithNews(prev, next) {
}

/** Composer slash commands the TUI handles itself; anything else is sent as typed. */
export const SLASH_COMMANDS = ['voice', 'archive', 'unarchive', 'archived'];
export const SLASH_COMMANDS = ['voice', 'archive', 'unarchive', 'archived', 'names', 'list'];

const pad2 = (n) => String(n).padStart(2, '0');
const timeOf = (iso) => {
Expand Down Expand Up @@ -305,7 +308,9 @@ export async function runTui(client, { initialChat } = {}) {
async function loadMessages(id) {
try {
const { messages } = await client.messages(id, { limit: 100 });
state.messages[id] = messages;
// Local /names and /list answers stay put between reloads.
const local = (state.messages[id] || []).filter((m) => m.local);
state.messages[id] = local.length ? [...messages, ...local].sort((a, b) => String(a.at).localeCompare(String(b.at))) : messages;
redraw();
} catch (err) {
note(`Could not load messages: ${err.message}`);
Expand Down Expand Up @@ -408,6 +413,14 @@ export async function runTui(client, { initialChat } = {}) {
state.field = { value: '', cursor: 0 };
if (cmd.name === 'archived') toggleArchivedView();
else if (!active) note('Pick a chat first');
else if (cmd.name === 'names' || cmd.name === 'list') {
// IRC-style, answered from the loaded chat list and shown only here.
const lines = cmd.name === 'names' ? namesLines(active, active.title) : listLines(state.conversations, (c) => c.title);
const at = new Date().toISOString();
const local = { id: `local-${Date.now()}`, senderId: 'qc', sender: `qc /${cmd.name}`, local: true, text: lines.join('\n'), at };
state.messages[active.id] = [...(state.messages[active.id] || []), local];
state.scrollBack = 0;
}
else if (cmd.name === 'voice') {
// Made here (src/lib/chat/voice.js) and sent inside the encrypted message.
const url = newVoiceRoomLink(process.env.PAIRUX_API_URL || PAIRUX_URL);
Expand Down
49 changes: 49 additions & 0 deletions src/lib/chat/irc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* IRC-style `/names` and `/list`, answered on this device and never sent.
*
* /names who is in the open chat (admins first, marked @; agents 🤖)
* /list the chats you are in, with how many people each has
*
* Both read the conversation list the client already loaded
* (/api/conversations/load), so they cost no request and reveal nothing the
* user could not already see in the sidebar.
*/

export const IRC_COMMANDS = ['names', 'list'];

const nameOf = (p) => {
const u = p.user || p;
return u.username || u.display_name || 'unknown';
};

/** Lines for `/names` in one conversation. */
export function namesLines(conversation, title = conversation?.name || 'this chat') {
const people = [...(conversation?.participants || [])].sort(
(a, b) => (b.role === 'admin') - (a.role === 'admin') || nameOf(a).localeCompare(nameOf(b))
);
const names = people.map((p) => {
const u = p.user || p;
const op = p.role === 'admin' ? '@' : p.role === 'moderator' ? '+' : '';
const agent = u.account_type === 'agent' ? ' 🤖' : '';
return `${op}${nameOf(p)}${agent}`;
});
const n = names.length;
return [`${title}: ${n} ${n === 1 ? 'person' : 'people'}`, names.join(' ') || '(nobody)'];
}

/**
* Lines for `/list`: your chats, groups first. `titleOf` names a chat (the
* other person for a direct chat); archived chats are counted, not listed.
*/
export function listLines(conversations, titleOf = (c) => c.name || 'Chat') {
const live = (conversations || []).filter((c) => !c.is_archived);
const rank = { group: 0, direct: 1, note_to_self: 2 };
const sorted = [...live].sort((a, b) => (rank[a.type] ?? 1) - (rank[b.type] ?? 1) || titleOf(a).localeCompare(titleOf(b)));
const lines = sorted.map((c) => {
const n = c.participants?.length ?? 0;
const kind = c.type === 'group' ? 'group' : c.type === 'note_to_self' ? 'note' : 'direct';
return `${c.type === 'group' ? '#' : ' '}${titleOf(c)} ${n} ${n === 1 ? 'person' : 'people'} · ${kind}`;
});
const archived = (conversations || []).length - live.length;
return [`${live.length} ${live.length === 1 ? 'chat' : 'chats'}${archived ? ` (+${archived} archived)` : ''}`, ...lines];
}
42 changes: 42 additions & 0 deletions src/lib/chat/irc.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { IRC_COMMANDS, listLines, namesLines } from './irc.js';
import { parseSlashCommand } from './voice.js';

const agents = {
id: 'g1',
type: 'group',
name: 'Agents',
participants: [
{ user_id: 'b', role: 'member', user: { username: 'riotcoder', account_type: 'agent' } },
{ user_id: 'c', role: 'member', user: { username: 'cha0spunk', account_type: 'agent' } },
{ user_id: 'a', role: 'admin', user: { username: 'chovy', account_type: 'user' } },
],
};

describe('IRC commands', () => {
it('parses /names and /list but not other slash text', () => {
expect(parseSlashCommand('/names', IRC_COMMANDS)).toEqual({ name: 'names', args: '' });
expect(parseSlashCommand(' /list ', IRC_COMMANDS)).toEqual({ name: 'list', args: '' });
expect(parseSlashCommand('/namesake', IRC_COMMANDS)).toBeNull();
expect(parseSlashCommand('/usr/bin is a path', IRC_COMMANDS)).toBeNull();
});

it('/names puts admins first with @ and marks agents', () => {
expect(namesLines(agents)).toEqual(['Agents: 3 people', '@chovy cha0spunk 🤖 riotcoder 🤖']);
});

it('/names counts one person', () => {
expect(namesLines({ participants: [{ role: 'member', user: { username: 'me' } }] }, 'Note to self')[0]).toBe('Note to self: 1 person');
});

it('/list shows groups first and counts archived chats', () => {
const dm = { id: 'd', type: 'direct', participants: [{}, {}] };
const old = { id: 'o', type: 'group', name: 'Old', is_archived: true, participants: [] };
const titleOf = (c) => c.name || 'Anthony';
expect(listLines([dm, agents, old], titleOf)).toEqual([
'2 chats (+1 archived)',
'#Agents 3 people · group',
' Anthony 2 people · direct',
]);
});
});
43 changes: 42 additions & 1 deletion src/lib/components/chat/MessageInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import EmojiEditor from './EmojiEditor.jsx';
import { snippet } from '@/lib/chat/reactions.js';
import { newVoiceRoomLink, parseSlashCommand, voiceMessage } from '@/lib/chat/voice.js';
import EmojiPicker, { Icon } from './EmojiPicker.jsx';
import { IRC_COMMANDS, listLines, namesLines } from '@/lib/chat/irc.js';

const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
const BLOCKED_EXTENSIONS = ['.exe', '.bat', '.cmd', '.scr', '.vbs', '.js'];
Expand Down Expand Up @@ -45,14 +46,16 @@ export default function MessageInput({ conversationId, disabled = false }) {
const [isUploadingFiles, setIsUploadingFiles] = useState(false);
const [selectedFiles, setSelectedFiles] = useState([]);
const [uploadError, setUploadError] = useState('');
const [notice, setNotice] = useState(null);
const [showEmoji, setShowEmoji] = useState(false);
const editorRef = useRef(null);
const fileInputRef = useRef(null);
const typingTimeoutRef = useRef(null);

const user = useAuthStore((s) => s.user);
const { sendMessage, setTyping, stopTyping, loadMessages, replyingTo, clearReplyingTo } = useChatStore(
const { conversations, sendMessage, setTyping, stopTyping, loadMessages, replyingTo, clearReplyingTo } = useChatStore(
useShallow((s) => ({
conversations: s.conversations,
sendMessage: s.sendMessage,
setTyping: s.setTyping,
stopTyping: s.stopTyping,
Expand All @@ -70,8 +73,31 @@ export default function MessageInput({ conversationId, disabled = false }) {
// A reply belongs to one conversation; switching chats drops it.
useEffect(() => {
clearReplyingTo();
setNotice(null);
}, [conversationId]);

// A chat's name, or the other people in it for a direct chat.
function titleOf(c) {
if (c.name) return c.name;
if (c.type === 'note_to_self') return 'Note to self';
const others = (c.participants || [])
.filter((p) => (p.user_id ?? p.id) !== user?.id)
.map((p) => p.user?.display_name || p.user?.username)
.filter(Boolean);
return others.join(', ') || 'Chat';
}

// IRC-style /names and /list: answered here from the loaded chat list, never sent.
function runIrcCommand(cmd) {
if (cmd.name === 'names') {
const active = conversations.find((c) => c.id === conversationId);
setNotice(active ? namesLines(active, titleOf(active)) : ['Pick a chat first']);
} else {
setNotice(listLines(conversations, titleOf));
}
setMessageText('');
}

function handleChange(val) {
setMessageText(val);
if (conversationId && user?.id) {
Expand Down Expand Up @@ -186,6 +212,8 @@ export default function MessageInput({ conversationId, disabled = false }) {
if (!conversationId || isSending || isUploadingFiles) return;

let content = messageText.trim();
const irc = !selectedFiles.length && parseSlashCommand(content, IRC_COMMANDS);
if (irc) return runIrcCommand(irc);
// /voice: post a fresh end-to-end encrypted PairUX room and open it here.
// window.open runs before any await so the popup blocker allows it.
if (!selectedFiles.length && parseSlashCommand(content, ['voice'])) {
Expand Down Expand Up @@ -256,6 +284,15 @@ export default function MessageInput({ conversationId, disabled = false }) {
<div className="message-input-container">
{uploadError && <div className="encryption-error">{uploadError}</div>}

{notice && (
<div className="irc-notice" role="status">
<button className="irc-notice-close" onClick={() => setNotice(null)} aria-label="Close" title="Close">×</button>
<div className="irc-notice-title">{notice[0]}</div>
{notice.slice(1).map((line, i) => <div key={i}>{line}</div>)}
<div className="irc-notice-hint">Only you see this.</div>
</div>
)}

{selectedFiles.length > 0 && (
<div className="file-preview-list">
{selectedFiles.map((file, i) => (
Expand Down Expand Up @@ -331,6 +368,10 @@ export default function MessageInput({ conversationId, disabled = false }) {

<style>{`
.message-input-container { position: absolute; bottom: 0; left: 0; right: 0; padding: .75rem 1rem; background: var(--color-bg-primary); border-top: 1px solid var(--color-border-primary); z-index: 10; }
.irc-notice { position: relative; font-family: var(--font-mono, ui-monospace, monospace); font-size: .8125rem; line-height: 1.5; margin-bottom: .5rem; padding: .5rem 2rem .5rem .75rem; background: var(--color-surface, rgba(127,127,127,.08)); border: 1px solid var(--color-border, rgba(127,127,127,.25)); border-radius: .375rem; max-height: 14rem; overflow: auto; white-space: pre-wrap; }
.irc-notice-title { font-weight: 600; }
.irc-notice-hint { opacity: .6; font-size: .75rem; margin-top: .25rem; }
.irc-notice-close { position: absolute; top: .25rem; right: .5rem; background: none; border: 0; color: inherit; font-size: 1rem; cursor: pointer; opacity: .7; }
.encryption-error { color: var(--color-error); font-size: .8125rem; margin-bottom: .5rem; padding: .5rem; background: rgba(239,68,68,.1); border-radius: .375rem; }
.file-preview-list { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .5rem; }
.file-chip { display: flex; align-items: center; gap: .4rem; max-width: 240px; padding: .35rem .5rem; background: var(--color-bg-tertiary); border: 1px solid var(--color-border-primary); border-radius: .5rem; font-size: .8125rem; color: var(--color-text-primary); }
Expand Down
Loading