aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-29 07:23:49 -0700
committerAdam Soltys <[email protected]>2026-08-29 07:23:49 -0700
commitafff4e068fa327b3420255b5c21c92e5d4683c4a (patch)
tree74545e51deb833a1e910ef7bd7a785f6c40c858d /src/features/messages.js
parentdd011c2c98033637829d83fc320ab4fdce4ede8c (diff)
downloadcoinosv3-afff4e068fa327b3420255b5c21c92e5d4683c4a.tar.gz
coinosv3-afff4e068fa327b3420255b5c21c92e5d4683c4a.zip
A half-typed message waits where you left it: drafts live per conversation (each DM and each channel its own), survive navigation and reloads through feature state on a typing debounce, and clear only when the message actually sends
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019WboeoetMF3MTmgT7Ew8yi
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js46
1 files changed, 34 insertions, 12 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index d5c0441..ea368ee 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -65,6 +65,7 @@ export function messagesFeature(ctx) {
s.tombstones ||= {}; // { [cid]: removed_at ms } — left communities (CORD-02 §8)
s.read ||= {}; // { ['dm:'+pk | 'ch:'+id]: created_at } — last message we've seen
s.notify ||= {}; // { [cid]: true } — communities that may buzz your phone (opt-in)
+ s.drafts ||= {}; // { ['dm:'+pk | 'ch:'+id]: text } — half-typed messages
for (const c of s.communities) c.added_at ||= Date.now();
// pre-multi-community shape: joined was { [pubkey]: true } for coinos
for (const k of Object.keys(s.joined))
@@ -404,18 +405,38 @@ export function messagesFeature(ctx) {
save(s);
}
+ // ---- drafts -------------------------------------------------------------
+ // Half-typed text survives navigation and reloads: one draft per
+ // conversation ('dm:<pk>' / 'ch:<channelId>'), kept in feature state.
+ // The session map is authoritative while the app runs; persistence rides a
+ // debounce so typing doesn't hammer storage.
+ const sessionDrafts = new Map();
+ let draftPersist = 0;
+ const draftFor = (key) =>
+ (sessionDrafts.has(key) ? sessionDrafts.get(key) : (st().drafts || {})[key] || '');
+ function setDraft(key, text) {
+ sessionDrafts.set(key, text);
+ clearTimeout(draftPersist);
+ draftPersist = setTimeout(() => {
+ const s = st();
+ s.drafts ||= {};
+ for (const [k, v] of sessionDrafts) { if (v) s.drafts[k] = v; else delete s.drafts[k]; }
+ save(s);
+ }, 800);
+ }
+
// The morph never rewrites a focused field's value (it would fight the user
// mid-keystroke), and the composer is focused at the moment you send — so
- // clearing ui.msgDraft alone leaves the sent text sitting in the input.
+ // clearing the draft alone leaves the sent text sitting in the input.
// Clear the live element too.
- function clearDraft() {
- ui.msgDraft = '';
+ function clearDraft(key) {
+ setDraft(key, '');
const inp = document.getElementById('msg-draft');
if (inp) inp.value = '';
}
async function sendMessage(room, chId) {
- const text = (ui.msgDraft || '').trim();
+ const text = draftFor('ch:' + chId).trim();
if (!text) return;
const id = await identity();
if (!id) { noIdToast(); return; }
@@ -431,7 +452,7 @@ export function messagesFeature(ctx) {
const msgs = room.byChannel.get(chId) || room.byChannel.set(chId, new Map()).get(chId);
const entry = { rumor, author: id.pubkey, pending: true };
msgs.set(rumor.id, entry);
- clearDraft();
+ clearDraft('ch:' + chId);
ui.msgStick = true;
render();
try {
@@ -1095,7 +1116,7 @@ export function messagesFeature(ctx) {
}
async function sendDM(peer) {
- const text = (ui.msgDraft || '').trim();
+ const text = draftFor('dm:' + peer).trim();
if (!text) return;
const id = await identity();
if (!id) { noIdToast(); return; }
@@ -1106,7 +1127,7 @@ export function messagesFeature(ctx) {
const rumor = makeDMRumor(id.pubkey, peer, text);
const entry = { rumor, mine: true, pending: true };
threadOf(peer).set(rumor.id, entry);
- clearDraft();
+ clearDraft('dm:' + peer);
ui.msgStick = true;
render();
try {
@@ -1986,14 +2007,14 @@ export function messagesFeature(ctx) {
}, ui.msgReconnecting ? h('span', { class: 'spinner sm' }) : t('msgReconnect')));
}
- const composer = (placeholder, onSend, onType) =>
+ const composer = (placeholder, onSend, onType, draftKey) =>
h('div', { class: 'col', style: 'gap:6px' },
signerNotice(),
h('div', { class: 'chat-compose' },
h('input', {
class: 'grow', type: 'text', id: 'msg-draft', placeholder,
- value: ui.msgDraft || '', maxlength: '2000',
- onInput: (e) => { ui.msgDraft = e.target.value; if (onType && e.target.value) onType(); },
+ value: draftFor(draftKey), maxlength: '2000',
+ onInput: (e) => { setDraft(draftKey, e.target.value); if (onType && e.target.value) onType(); },
onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); } },
// Focus summons the keyboard; some browsers resize only after its
// animation, so re-stick once now and once when it has settled.
@@ -2305,7 +2326,8 @@ export function messagesFeature(ctx) {
composer(
chans.length > 1 ? t('msgPlaceholder', { channel: ch ? ch.name : '' }) : t('msgPlaceholderPlain'),
() => ch && sendMessage(room, ch.id),
- () => ch && ping(room, ch.id, TYPING)));
+ () => ch && ping(room, ch.id, TYPING),
+ ch ? 'ch:' + ch.id : 'ch:'));
}
// "Alice is typing…" — named up to two, counted beyond that.
@@ -2421,7 +2443,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'chat-bubble' + (m.mine ? ' me' : '') }, m.rumor.content),
h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000)))))
: [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])),
- composer(t('msgDmPlaceholder'), () => sendDM(peer)));
+ composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer));
}
// ---- feature ------------------------------------------------------------