From b19aec9cdf864166ba25b4463b78fe9330dff06a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 27 Oct 2026 04:10:16 +0000 Subject: [PATCH] feat(qc): message anyone new from the TUI, CLI and MCP (0.17.0, qc 0.13.0) Anthony: "qrypt.chat tui has no way to message a new user." qc could only talk in chats that already existed. Both server routes a new chat needs were cookie-only, so even a hand-rolled call from qc got 401: - /api/users/search and POST /api/chat/conversations now authenticate with authenticateRequest (Bearer or cookie), like the rest of the API. - A new chat is announced (announceJoined): every member's open devices put it in their list at once and join its live room. qc: - TUI: /msg @name [first message] (also /dm, /query, /new) finds the person, opens or reuses your direct chat with them and sends the text. `n` in the chat list types "/msg @" for you; the empty screen says how. - CLI: qc dm <@user> [text] (qc msg is the same; "-" reads stdin). - MCP: start_direct_chat { user, text? }. - QcClient.searchUsers / startDirect; pickUser takes an exact @username, else the only match, else lists who it could be. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/qryptchat/README.md | 1 + packages/qryptchat/package.json | 2 +- src/app/api/chat/conversations/route.js | 15 ++++-- src/app/api/users/search/route.js | 11 ++--- src/cli/api.js | 38 +++++++++++++++ src/cli/commands.js | 15 +++++- src/cli/mcp.js | 19 ++++++++ src/cli/tui.js | 64 +++++++++++++++++++++---- tests/cli/qc.test.js | 42 +++++++++++++++- tests/cli/tui.test.js | 28 ++++++++++- 11 files changed, 210 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 3c82ad96..b18cd269 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.16.1", + "version": "0.17.0", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/README.md b/packages/qryptchat/README.md index 8eb271bb..c7b5c2a9 100644 --- a/packages/qryptchat/README.md +++ b/packages/qryptchat/README.md @@ -49,6 +49,7 @@ The mouse works too: click a chat, scroll the transcript, click an emoji. Unread ```sh qc chats # id and name of every chat qc read alice -n 20 # the last 20 messages, decrypted +qc dm @alice "hey, it's me" # start (or reopen) a direct chat with anyone, by @username qc send alice "on my way" # encrypted to every participant qc send ops "@ri deploy is done" # an @partial matching one person is sent as @riotcoder qc mentions ops @al # who @al completes to: @alice, @alina diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index 98324bc0..c2003114 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.12.0", + "version": "0.13.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": { diff --git a/src/app/api/chat/conversations/route.js b/src/app/api/chat/conversations/route.js index 95678e2d..de51a797 100644 --- a/src/app/api/chat/conversations/route.js +++ b/src/app/api/chat/conversations/route.js @@ -1,5 +1,8 @@ import { NextResponse } from 'next/server'; import { createSupabaseServerClient } from '@/lib/supabase.js'; +import { authenticateRequest } from '@/lib/api/middleware/auth.js'; +import { sseManager } from '@/lib/api/sse-manager.js'; +import { announceJoined } from '@/lib/chat/membership-events.js'; export async function GET(request, { params } = {}) { @@ -84,12 +87,11 @@ export async function GET(request, { params } = {}) { export async function POST(request, { params } = {}) { try { - const supabase = await createSupabaseServerClient(); const { type, name, participant_ids, group_id } = await request.json(); - - // Get user from session - const { data: { user }, error: userError } = await supabase.auth.getUser(); - if (userError || !user) { + + // Bearer (qc, MCP, passkey and CoinPay sessions) or the cookie session. + const { user, supabase } = await authenticateRequest(request); + if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -225,6 +227,9 @@ export async function POST(request, { params } = {}) { return NextResponse.json({ error: 'Failed to add participants' }, { status: 500 }); } + // Every member's open devices get the new chat in their list now. + announceJoined(sseManager, conversationId, participants.map((p) => p.user_id)); + return NextResponse.json({ conversation_id: conversationId, message: 'Conversation created successfully' diff --git a/src/app/api/users/search/route.js b/src/app/api/users/search/route.js index 7f1768d6..805fb4ab 100644 --- a/src/app/api/users/search/route.js +++ b/src/app/api/users/search/route.js @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { createSupabaseServerClient } from '@/lib/supabase.js'; +import { authenticateRequest } from '@/lib/api/middleware/auth.js'; import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; // Directory search has to reach across every user, which is the one thing the narrowed @@ -16,13 +16,12 @@ const MIN_PHONE_QUERY_LENGTH = 7; export async function GET(request, { params } = {}) { try { - const supabase = await createSupabaseServerClient(); const url = new URL(request.url); const query = url.searchParams.get('q'); - - // Get user from session - const { data: { user }, error: userError } = await supabase.auth.getUser(); - if (userError || !user) { + + // Bearer (qc, MCP, passkey and CoinPay sessions) or the cookie session. + const { user } = await authenticateRequest(request); + if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/cli/api.js b/src/cli/api.js index bf2ca9c6..40d38e95 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -195,6 +195,30 @@ export class QcClient { return { messages: out, hasMore }; } + /** People on qrypt.chat matching a username, name or unique id (best first, never you). */ + async searchUsers(query) { + const q = String(query ?? '').trim().replace(/^@/, ''); + if (!q) return []; + const { users = [] } = await this.request(`/api/users/search?q=${encodeURIComponent(q)}`); + return users; + } + + /** + * Open a direct chat with one person, given their @username (or a name that + * matches only them). An existing chat between the two of you is reused. + * Returns the chat as conversations() lists it. + */ + async startDirect(who) { + const user = pickUser(await this.searchUsers(who), who); + const { conversation_id: id } = await this.request('/api/chat/conversations', { + method: 'POST', + body: { type: 'direct', participant_ids: [user.id] }, + }); + if (!id) throw new QcError(`Could not start a chat with @${user.username}`); + const chat = (await this.conversations()).find((c) => c.id === id); + return chat ?? { id, title: user.display_name || user.username, participants: [] }; + } + /** Join a group from its invite link or code. Returns { conversation_id, joined }. */ async joinGroup(code) { return this.request('/api/conversations/join', { method: 'POST', body: { code } }); @@ -330,6 +354,20 @@ function sleep(ms, signal) { }); } +/** + * The one person `query` means among search results: an exact @username (or + * unique id) first, else the only result. Anything else says who it could be. + */ +export function pickUser(users, query) { + const q = String(query ?? '').trim().replace(/^@/, '').toLowerCase(); + const exact = users.filter((u) => u.username?.toLowerCase() === q || u.unique_identifier?.toLowerCase() === q); + if (exact.length === 1) return exact[0]; + if (users.length === 1) return users[0]; + if (!users.length) throw new QcError(`Nobody on qrypt.chat matches "${query}"`, 404); + const names = users.slice(0, 8).map((u) => `@${u.username}${u.display_name && u.display_name !== u.username ? ` (${u.display_name})` : ''}`); + throw new QcError(`"${query}" matches ${users.length} people: ${names.join(', ')}${users.length > 8 ? ', …' : ''}. Use their @username.`, 409); +} + /** A conversation's name, or the other participants' names for a direct chat. */ export function conversationTitle(c, me) { if (c.name) return c.name; diff --git a/src/cli/commands.js b/src/cli/commands.js index 816bd1da..0f302741 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -48,10 +48,12 @@ Usage: qc read [-n 20] print the last messages of a chat qc send send a message (text "-" reads stdin; --reply to reply); an @partial that matches one person becomes their @username + qc dm <@user> [text] start (or reopen) a direct chat with anyone on qrypt.chat and send + text if given ("-" reads stdin); qc msg is the same. In the client: /msg @user qc mentions [@partial] who @ completes to in a chat, best match first (one per line) qc react [--remove] react to a message qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it) - qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions) + qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, start_direct_chat, list_mentions) qc join join a group chat from its invite link (https://qrypt.chat/join/...) qc invite print a group's invite link (group admins) [--reset makes a new one, --off turns it off] @@ -367,6 +369,17 @@ export async function main(argv, { version = '0.0.0' } = {}) { for (const a of m.ambiguous) out(warn(`@${a.query} could be ${a.matches.join(', ')}: sent as typed.`)); return out(`${mark('send')}Sent to ${chat.title}.`); } + case 'dm': + case 'msg': { + if (!rest[0]) throw new Error(`Usage: qc ${cmd} <@user> [text] (a username, or a name only one person has)`); + const text = rest[1] === '-' && rest.length === 2 ? (await readStdin()).trim() : rest.slice(1).join(' '); + const c = await client(flags); + const chat = await c.startDirect(rest[0]); + const sent = text ? await c.send(chat.id, text) : null; + if (flags.json) return json({ conversation: chat.id, title: chat.title, ...(sent ? { id: sent.message?.id, skipped: sent.skipped } : {}) }); + if (sent?.skipped) out(warn(`${chat.title} has no key yet and will not see it until they sign in.`)); + return out(`${mark('send')}${sent ? `Sent to ${chat.title}` : `Chat with ${chat.title} is open (qc send "${chat.title}" )`}.`); + } case 'listen': { const c = await client(flags); const chats = await c.conversations(); diff --git a/src/cli/mcp.js b/src/cli/mcp.js index 17b4196d..5c9239e0 100644 --- a/src/cli/mcp.js +++ b/src/cli/mcp.js @@ -39,6 +39,19 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: 'start_direct_chat', + description: 'Start (or reopen) an end-to-end encrypted direct chat with anyone on qrypt.chat, by @username or a name only one person has, and optionally send the first message. Returns the chat id for read_chat/send_message.', + inputSchema: { + type: 'object', + properties: { + user: { type: 'string', description: 'Their @username (or a name that matches only them)' }, + text: { type: 'string', description: 'Optional: the first message' }, + }, + required: ['user'], + additionalProperties: false, + }, + }, { name: 'list_mentions', description: 'Who an @mention can complete to in a conversation, best match first (like typing @ali then Tab). Without partial: everyone you can mention.', @@ -97,6 +110,12 @@ export async function callTool(client, name, args = {}) { const { message, skipped } = await client.send(chat.id, m.text, { replyTo: typeof args.reply_to === 'string' ? args.reply_to : undefined }); return { sent: true, id: message?.id, chat: chat.title, skipped, ...(m.mentions.length ? { mentions: m.mentions } : {}), ...(m.ambiguous.length ? { ambiguous: m.ambiguous } : {}) }; } + if (name === 'start_direct_chat') { + if (!args.user || typeof args.user !== 'string') throw new Error('user is required'); + const chat = await client.startDirect(args.user); + const sent = typeof args.text === 'string' && args.text.trim() ? await client.send(chat.id, args.text.trim()) : null; + return { chat: chat.title, id: chat.id, ...(sent ? { sent: true, message_id: sent.message?.id, skipped: sent.skipped } : {}) }; + } if (name === 'list_mentions') { const chat = findChat(await client.conversations(), args.chat); const matches = mentionMatches(chat, client.me, typeof args.partial === 'string' ? args.partial : ''); diff --git a/src/cli/tui.js b/src/cli/tui.js index 37d16da1..e1a276ff 100644 --- a/src/cli/tui.js +++ b/src/cli/tui.js @@ -12,7 +12,9 @@ * └─────────────┘└──────────────────────────────────────────────┘ * ENTER send CTRL+E emoji TAB chats PGUP scroll ● live * - * Composer commands: /voice posts a fresh end-to-end encrypted PairUX room, + * Composer commands: /msg @name [text] starts (or reopens) a direct chat with + * anyone on qrypt.chat, IRC-style (/dm, /query and /new too; `n` in the list + * types it for you), /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 @@ -95,7 +97,16 @@ 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', 'names', 'list']; +export const SLASH_COMMANDS = ['voice', 'archive', 'unarchive', 'archived', 'names', 'list', 'msg', 'dm', 'query', 'new']; + +/** The slash commands that start a direct chat: /msg @name [first message]. */ +export const NEW_CHAT_COMMANDS = ['msg', 'dm', 'query', 'new']; + +/** "/msg @alice hi there" args as { who: '@alice', text: 'hi there' }. */ +export function parseNewChat(args) { + const m = /^(\S+)(?:\s+([\s\S]*))?$/.exec(String(args ?? '').trim()); + return m ? { who: m[1], text: (m[2] ?? '').trim() } : null; +} const pad2 = (n) => String(n).padStart(2, '0'); const timeOf = (iso) => { @@ -156,7 +167,8 @@ function drawTranscript(surface, state) { const theme = surface.theme; const messages = state.messages[state.activeId] || []; if (!state.activeId) { - surface.text(1, 1, state.loading ? 'Loading your chats…' : 'Pick a chat on the left.', { fg: theme.muted }); + const hint = state.loading ? 'Loading your chats…' : state.conversations.length ? 'Pick a chat on the left, or type /msg @name to start one.' : 'No chats yet. Type /msg @name to message someone.'; + surface.text(1, 1, hint, { fg: theme.muted }); return; } if (!messages.length) { @@ -256,7 +268,7 @@ export function render({ ui, width, height, theme }, state, on = {}) { p.textInput({ value: state.field.value, cursor: state.field.cursor, - placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, :rocket: works too)` : 'Pick a chat first', + placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, /msg @name for a new chat)` : '/msg @name to start a chat', focused: state.focus === 'compose' && !state.picker, size: 1, }); @@ -270,7 +282,7 @@ export function render({ ui, width, height, theme }, state, on = {}) { { key: 'Ctrl+E', label: 'emoji' }, { key: 'Tab', label: state.focus === 'list' ? 'compose' : mention?.matches.length ? 'complete @' : 'chats' }, { key: 'PgUp', label: 'scroll' }, - ...(state.focus === 'list' ? [{ key: 'a', label: state.showArchived ? 'unarchive' : 'archive' }] : []), + ...(state.focus === 'list' ? [{ key: 'n', label: 'new chat' }, { key: 'a', label: state.showArchived ? 'unarchive' : 'archive' }] : []), { key: 'Ctrl+O', label: state.showArchived ? 'chats' : 'archived' }, { key: 'Ctrl+C', label: 'quit' }, ], @@ -439,13 +451,43 @@ export async function runTui(client, { initialChat } = {}) { } } + /** Open a chat already in state.conversations, switching to the archived view if that is where it is. */ + function openById(id) { + const chat = state.conversations.find((c) => c.id === id); + if (!chat) return; + state.showArchived = !!chat.is_archived; + openAt(listed(state).findIndex((c) => c.id === id)); + state.focus = 'compose'; + } + + /** /msg @name [text]: find the person, open (or reuse) your direct chat, send the text if any. */ + async function startChat(args) { + const want = parseNewChat(args); + if (!want) return note('Usage: /msg @name [message] (a username, or a name only one person has)'); + note(`Looking up ${want.who}…`, { sticky: true }); + try { + const chat = await client.startDirect(want.who); + if (!state.conversations.some((c) => c.id === chat.id)) state.conversations = [chat, ...state.conversations]; + openById(chat.id); + note(`Chat with ${chat.title} is open`); + if (want.text) { + state.field = { value: want.text, cursor: want.text.length }; + await send({ text: true }); + } + } catch (err) { + note(err.message, { sticky: err.status === 409 }); + } + redraw(); + } + /** A slash command typed in the composer; true when it was one. */ function runSlash(raw) { const cmd = parseSlashCommand(raw, SLASH_COMMANDS); if (!cmd) return false; const active = state.conversations.find((c) => c.id === state.activeId); state.field = { value: '', cursor: 0 }; - if (cmd.name === 'archived') toggleArchivedView(); + if (NEW_CHAT_COMMANDS.includes(cmd.name)) startChat(cmd.args); + else 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. @@ -465,8 +507,9 @@ export async function runTui(client, { initialChat } = {}) { return true; } - async function send({ raw = false } = {}) { - if (!raw && runSlash(state.field.value)) return; + /** raw: send exactly as typed (no :shortcodes:); text: plain text, never a slash command. */ + async function send({ raw = false, text: plain = false } = {}) { + if (!raw && !plain && runSlash(state.field.value)) return; const text = raw ? state.field.value.trim() : emojify(state.field.value.trim(), { mode: 'emoji' }); if (!text || !state.activeId) return; const id = state.activeId; @@ -544,7 +587,10 @@ export async function runTui(client, { initialChat } = {}) { if (event.key === 'up' || event.key === 'k') state.selected = Math.max(0, state.selected - 1); else if (event.key === 'down' || event.key === 'j') state.selected = Math.min(listed(state).length - 1, state.selected + 1); else if (event.key === 'enter' || event.key === 'right' || event.key === 'l') handlers.open(state.selected); - else if (event.key === 'a') { + else if (event.key === 'n') { + state.field = { value: '/msg @', cursor: 6 }; + state.focus = 'compose'; + } else if (event.key === 'a') { const c = listed(state)[state.selected]; if (c) setArchived(c, !c.is_archived); } diff --git a/tests/cli/qc.test.js b/tests/cli/qc.test.js index 5bdda4df..a61b34d9 100644 --- a/tests/cli/qc.test.js +++ b/tests/cli/qc.test.js @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { ephemeralKeypair, keyring, openKeyBlob } from '../../src/cli/crypto.js'; -import { QcClient, readSse, conversationTitle } from '../../src/cli/api.js'; +import { QcClient, readSse, conversationTitle, pickUser } from '../../src/cli/api.js'; import { parseArgs, findChat } from '../../src/cli/commands.js'; import { handle } from '../../src/cli/mcp.js'; import { matchCode } from '../../src/lib/auth/cli-match.js'; @@ -180,7 +180,7 @@ describe('qc mcp', () => { const init = await handle(fake, { id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }, { version: '1' }); expect(init.serverInfo.name).toBe('qc'); const { tools } = await handle(fake, { id: 2, method: 'tools/list' }, { version: '1' }); - expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message', 'list_mentions', 'react_to_message']); + expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message', 'start_direct_chat', 'list_mentions', 'react_to_message']); }); it('calls tools and reports errors as tool errors', async () => { @@ -251,3 +251,41 @@ describe('qc login on a remote box', () => { expect(parsePasted('', 'abc')).toBeNull(); }); }); + +describe('starting a direct chat', () => { + const people = [ + { id: 'u1', username: 'alice', display_name: 'Alice Liddell' }, + { id: 'u2', username: 'alicia', display_name: 'Alicia' }, + ]; + + it('picks an exact @username, else the only match, else names the candidates', () => { + expect(pickUser(people, '@alice').id).toBe('u1'); + expect(pickUser(people, 'ALICE').id).toBe('u1'); + expect(pickUser([people[1]], 'ali').id).toBe('u2'); + expect(() => pickUser(people, 'ali')).toThrow(/matches 2 people: @alice \(Alice Liddell\), @alicia/); + expect(() => pickUser([], 'zed')).toThrow(/Nobody on qrypt.chat matches "zed"/); + }); + + it('searches with the Bearer token, creates the direct chat and returns it from the chat list', async () => { + const calls = []; + const fetch = vi.fn(async (url, init) => { + const u = new URL(url); + calls.push(`${init?.method || 'GET'} ${u.pathname}${u.search}`); + expect(init.headers.Authorization).toBe('Bearer tok'); + if (u.pathname === '/api/users/search') return json({ users: people }); + if (u.pathname === '/api/chat/conversations') { + expect(JSON.parse(init.body)).toEqual({ type: 'direct', participant_ids: ['u1'] }); + return json({ conversation_id: 'c9' }); + } + if (u.pathname === '/api/conversations/load') return json({ conversations: [{ id: 'c9', participants: [{ user_id: 'me' }, { user_id: 'u1', user: { username: 'alice', display_name: 'Alice Liddell' } }] }] }); + return json({ error: 'nope' }, 404); + }); + const client = new QcClient( + { base: 'https://chat.test', access_token: 'tok', refresh_token: 'r', expires_at: Date.now() / 1000 + 3600, user: { id: 'me' }, keys: await account() }, + { fetch, save: () => {} }, + ); + const chat = await client.startDirect('@alice'); + expect(chat).toMatchObject({ id: 'c9', title: 'Alice Liddell' }); + expect(calls).toEqual(['GET /api/users/search?q=alice', 'POST /api/chat/conversations', 'POST /api/conversations/load']); + }); +}); diff --git a/tests/cli/tui.test.js b/tests/cli/tui.test.js index b1a6e477..f9a6324a 100644 --- a/tests/cli/tui.test.js +++ b/tests/cli/tui.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { renderToScreen } from '@profullstack/hqtui/testing'; import { setIconMode } from '@profullstack/hqtui'; -import { CHAT_LIST_REFRESH_MS, chatsWithNews, initialState, listed, mentionSuggestions, render, SLASH_COMMANDS, tabComplete, transcriptLines } from '../../src/cli/tui.js'; +import { CHAT_LIST_REFRESH_MS, chatsWithNews, initialState, listed, mentionSuggestions, NEW_CHAT_COMMANDS, parseNewChat, render, SLASH_COMMANDS, tabComplete, transcriptLines } from '../../src/cli/tui.js'; setIconMode('unicode'); @@ -89,7 +89,7 @@ describe('the qc client screen', () => { const s = initialState({ username: 'x' }); expect(draw(s).text()).toContain('Loading your chats'); s.loading = false; - expect(draw(s).text()).toContain('Pick a chat on the left'); + expect(draw(s).text()).toContain('No chats yet. Type /msg @name'); }); }); @@ -180,3 +180,27 @@ describe('@mention completion in the composer', () => { expect(text).toMatch(/TAB\s+complete @/i); }); }); + +describe('messaging someone new from the qc client', () => { + it('/msg, /dm, /query and /new start a direct chat, with an optional first message', () => { + expect(SLASH_COMMANDS).toEqual(expect.arrayContaining(NEW_CHAT_COMMANDS)); + expect(NEW_CHAT_COMMANDS).toEqual(expect.arrayContaining(['msg', 'dm', 'query', 'new'])); + expect(parseNewChat('@alice hi there\nsecond line')).toEqual({ who: '@alice', text: 'hi there\nsecond line' }); + expect(parseNewChat(' bob ')).toEqual({ who: 'bob', text: '' }); + expect(parseNewChat('')).toBeNull(); + }); + + it('tells someone with no chats how to start one', () => { + const s = initialState({ id: 'me', username: 'anthony' }); + s.loading = false; + const text = draw(s).text(); + expect(text).toContain('No chats yet. Type /msg @name to message someone.'); + expect(text).toContain('/msg @name to start a chat'); + }); + + it('offers n for a new chat in the chat list', () => { + const s = state(); + s.focus = 'list'; + expect(draw(s).text()).toContain('new chat'); + }); +});