Family Chat phases 2 and 3, family extras; genre search on the roadmap
build-container / build (push) Successful in 8m12s
build-container / build (push) Successful in 8m12s
- Ticks (sent, delivered, read), typing, presence, disappearing messages, @mentions, pins, forwarding - Voice messages with a waveform, link previews, polls, emoji picker - Message to task, shopping item or calendar event; Share to Chat from recipes and songs; quiet hours; family announcement on Home - Roadmap: chat items ticked off, and Browse and search by genre added Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8283d10b41
commit
41aa4e676d
10 files changed
+379
-58
No files matched your search
@@ -44,6 +44,50 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
|
||||
await post('/conv/mute', { id: dm.id, on: true }, 'b'); await post('/send', { conv: dm.id, text: 'psst' }, 'a'); let bl = await j(await call('/convs', {}, 'b')); assert.equal(bl.convs.find(x => x.id === dm.id).muted, true); assert.equal(bl.unread, 0, 'a muted chat does not add to the badge'); assert.equal(bl.convs.find(x => x.id === dm.id).unread, 1, 'but still counts in itself');
|
||||
await post('/conv/archive', { id: dm.id, on: true }, 'b'); assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === dm.id).archived, true);
|
||||
const s = await j(await call('/search?q=hello', {}, 'b')); assert.equal(s.results.length, 1); assert.equal(s.results[0].convName, 'Ann'); assert.equal((await j(await call('/search?q=h', {}, 'b'))).results.length, 0, 'too short');
|
||||
|
||||
// ---- phase 2: ticks, typing, presence, disappearing messages, mentions, pins, forwarding
|
||||
const g2 = (await j(await post('/new', { type: 'group', name: 'Crew', members: [P.b.id, P.c.id] }, 'a'))).conv; const st = async (who, conv) => (await j(await call('/conv/' + encodeURIComponent(conv), {}, who)));
|
||||
let sm = (await j(await post('/send', { conv: g2.id, text: 'Hi @Bob and @Cy!' }, 'a'))).msg; assert.deepEqual(sm.mentions.sort(), [P.b.id, P.c.id].sort(), 'mentions are found by name'); assert.equal(sm.status, 'sent', 'nobody has the app open: just sent');
|
||||
await st('b', g2.id); let a1 = (await st('a', g2.id)).msgs.find(x => x.id === sm.id); assert.equal(a1.status, 'sent', 'delivered needs everyone in a group'); await st('c', g2.id); a1 = (await st('a', g2.id)).msgs.find(x => x.id === sm.id); assert.equal(a1.status, 'delivered', 'everyone has fetched it');
|
||||
await post('/read', { id: g2.id }, 'b'); assert.equal((await st('a', g2.id)).msgs.find(x => x.id === sm.id).status, 'delivered', 'read by one is not read by all'); await post('/read', { id: g2.id }, 'c'); assert.equal((await st('a', g2.id)).msgs.find(x => x.id === sm.id).status, 'read');
|
||||
assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sm.id).status, undefined, 'ticks are only for what you sent');
|
||||
// typing and presence (Bob has the live stream open)
|
||||
const t0 = events.length; await post('/typing', { conv: dm.id }, 'a'); await new Promise(ok => setTimeout(ok, 120)); const ty = events.slice(t0).find(e => e.t === 'typing'); assert.ok(ty && ty.name === 'Ann' && ty.conv === dm.id, 'Bob sees that Ann is typing');
|
||||
let pv = (await j(await call('/convs', {}, 'a'))).convs.find(x => x.id === dm.id); assert.equal(pv.presence.online, true, 'Bob has the app open'); assert.ok(pv.presence.seen > 0);
|
||||
// disappearing messages
|
||||
assert.equal((await post('/conv/ttl', { id: g2.id, ttl: 5 }, 'a')).status, 400); assert.equal((await post('/conv/ttl', { id: g2.id, ttl: 3600 }, 'b')).status, 200);
|
||||
let gv = await st('a', g2.id); assert.equal(gv.conv.ttl, 3600); const sys = gv.msgs.at(-1); assert.equal(sys.sys, true); assert.match(sys.text, /Bob set messages to disappear after 1 hour/); assert.equal((await j(await call('/convs', {}, 'a'))).convs.find(x => x.id === g2.id).preview, 'Bob set messages to disappear after 1 hour', 'a notice has no name in front');
|
||||
const upg = await j(await call('/upload?conv=' + g2.id + '&name=t.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); const tm = (await j(await post('/send', { conv: g2.id, text: 'secret', files: [upg.id] }, 'a'))).msg; assert.ok(tm.exp > Date.now(), 'it has a time to go');
|
||||
assert.equal((await call('/file/' + upg.id, {}, 'b')).status, 200); const realNow = Date.now; Date.now = () => realNow() + 2 * 3600e3; chat.purge(); Date.now = realNow;
|
||||
gv = await st('a', g2.id); assert.ok(!gv.msgs.some(x => x.id === tm.id), 'it is gone when its time is up'); assert.ok(gv.msgs.some(x => x.id === sm.id), 'older messages from before the timer stay'); assert.equal((await call('/file/' + upg.id, {}, 'b')).status, 404, 'and so is its file');
|
||||
// pins
|
||||
assert.equal((await post('/pin', { conv: g2.id, msg: sm.id, on: true }, 'c')).status, 200); gv = await st('b', g2.id); assert.deepEqual(gv.pinned.map(x => x.id), [sm.id]); await post('/pin', { conv: g2.id, msg: sm.id, on: false }, 'b'); assert.deepEqual((await st('a', g2.id)).pinned, []);
|
||||
assert.equal((await post('/pin', { conv: g2.id, msg: (await st('a', g2.id)).msgs.find(x => x.sys).id, on: true }, 'a')).status, 404, 'a notice cannot be pinned');
|
||||
// forwarding copies the message and its file into another chat
|
||||
const fp = await j(await call('/upload?conv=' + g2.id + '&name=f.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); const fm = (await j(await post('/send', { conv: g2.id, text: 'look at this', files: [fp.id] }, 'a'))).msg;
|
||||
const fw = (await j(await post('/forward', { from: g2.id, to: dm.id, msg: fm.id }, 'a'))).msg; assert.equal(fw.text, 'look at this'); assert.equal(fw.fwd.name, 'Ann'); assert.notEqual(fw.att[0].id, fp.id, 'the file is copied, not shared'); assert.equal((await call('/file/' + fw.att[0].id, {}, 'b')).status, 200);
|
||||
assert.equal((await post('/forward', { from: g2.id, to: dm.id, msg: fm.id }, 'c')).status, 404, 'only into chats you are in');
|
||||
|
||||
|
||||
// ---- phase 3: voice notes, polls, link previews
|
||||
const vp = await j(await call('/upload?conv=' + g2.id + '&name=Voice.m4a&voice=1&wave=10,50,200,-5,abc,80&dur=12', { method: 'POST', body: 'audio-bytes', headers: { 'content-type': 'audio/mp4' } }, 'a')); assert.equal(vp.voice, true);
|
||||
const vm = (await j(await post('/send', { conv: g2.id, files: [vp.id] }, 'a'))).msg; assert.deepEqual(vm.att[0].wave, [10, 50, 100, 0, 80], 'the waveform is cleaned (0 to 100)'); assert.equal(vm.att[0].dur, 12); assert.equal(vm.att[0].voice, true);
|
||||
assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, 'Ann: 🎤 Voice message', 'a voice note with no words is listed as one');
|
||||
const notV = await j(await call('/upload?conv=' + g2.id + '&name=x.gif&voice=1', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); assert.equal(notV.voice, undefined, 'only audio can be a voice note');
|
||||
assert.equal((await post('/poll', { conv: g2.id, question: 'Pizza?', options: ['Yes'] }, 'a')).status, 400, 'a poll needs two choices'); const pm = (await j(await post('/poll', { conv: g2.id, question: 'Pizza or tacos?', options: ['Pizza', 'Tacos', ' ', 'Salad'] }, 'a'))).msg; assert.deepEqual(pm.poll.options.map(o => o.text), ['Pizza', 'Tacos', 'Salad']); assert.equal(pm.poll.multi, false);
|
||||
assert.equal((await post('/vote', { conv: g2.id, msg: pm.id, opts: ['0', '1', '9'] }, 'b')).status, 200); let pv2 = (await st('a', g2.id)).msgs.find(x => x.id === pm.id); assert.deepEqual(pv2.poll.votes, { [P.b.id]: ['0'] }, 'a single-choice poll keeps one vote');
|
||||
await post('/vote', { conv: g2.id, msg: pm.id, opts: ['1'] }, 'c'); await post('/vote', { conv: g2.id, msg: pm.id, opts: ['2'] }, 'b'); pv2 = (await st('a', g2.id)).msgs.find(x => x.id === pm.id); assert.deepEqual(pv2.poll.votes, { [P.b.id]: ['2'], [P.c.id]: ['1'] }, 'changing a vote replaces it'); await post('/vote', { conv: g2.id, msg: pm.id, opts: [] }, 'c'); assert.deepEqual((await st('a', g2.id)).msgs.find(x => x.id === pm.id).poll.votes, { [P.b.id]: ['2'] }, 'an empty vote takes it back');
|
||||
const mp = (await j(await post('/poll', { conv: g2.id, question: 'Which days?', options: ['Sat', 'Sun'], multi: true }, 'a'))).msg; await post('/vote', { conv: g2.id, msg: mp.id, opts: ['0', '1'] }, 'b'); assert.deepEqual((await st('a', g2.id)).msgs.find(x => x.id === mp.id).poll.votes[P.b.id], ['0', '1'], 'a multiple-choice poll keeps both');
|
||||
assert.equal((await post('/poll/close', { conv: g2.id, msg: pm.id }, 'b')).status, 403, 'only the asker closes it'); assert.equal((await post('/poll/close', { conv: g2.id, msg: pm.id }, 'a')).status, 200); assert.equal((await post('/vote', { conv: g2.id, msg: pm.id, opts: ['0'] }, 'b')).status, 400, 'a closed poll takes no votes');
|
||||
assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, 'Ann: 📊 Which days?', 'a poll is listed by its question');
|
||||
const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot & Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
|
||||
assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null);
|
||||
|
||||
|
||||
// ---- family extras: announcement and quiet hours
|
||||
assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');
|
||||
assert.deepEqual((await j(await call('/prefs', {}, 'b'))).quiet, { on: false, from: '21:00', to: '07:00' }); const qp = await j(await post('/prefs', { quiet: { on: true, from: '20:30', to: 'nope' } }, 'b')); assert.deepEqual(qp.quiet, { on: true, from: '20:30', to: '07:00' }, 'a bad time is ignored'); assert.equal((await j(await call('/prefs', {}, 'c'))).quiet.on, false, 'each person has their own');
|
||||
|
||||
ctl.abort(); console.log('chat ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 80);
|
||||
|
||||
Reference in new issue
Block a user