aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js96
1 files changed, 86 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index a42396c..85b44f3 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1607,6 +1607,7 @@ export function messagesFeature(ctx) {
for (const room of wanted) subscribeRoom(room);
if (wn) wn.resubscribe();
if (cd) cd.resubscribe();
+ if (ng) ng.resubscribe();
}
// ---- subscription watchdog ------------------------------------------------
@@ -3735,8 +3736,43 @@ export function messagesFeature(ctx) {
bumpMsgRev();
scheduleRepaint();
}
- const gClient = (g) => (g && g.cordn ? cd : wn);
- const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || null;
+ // NIP-29 groups (nip29-client.js): a group on one relay that keeps its
+ // members and signs its metadata; messages are plain signed events with
+ // an `h` tag. Not encrypted. Joined by address (host'id, a client's link),
+ // and the list of them is the NIP-51 kind 10009 other clients keep too.
+ let ng = null, ngLoading = null;
+ function nip29Chat() {
+ const pk = identityPk();
+ if (!pk || !wallet._cacheKey) return Promise.resolve(null);
+ if (ng && ng.pubkey === pk) return Promise.resolve(ng);
+ if (ngLoading && ngLoading.pk === pk) return ngLoading.p;
+ if (ng) { try { ng.stop(); } catch {} ng = null; }
+ const p = import('../nip29-client.js').then(async (m) => {
+ if (identityPk() !== pk) return null;
+ const c = m.nip29Client({
+ scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onNg,
+ listRelays: async (deep) => deep
+ ? [...new Set([...zapRelays(), ...PROFILE_INDEX_RELAYS, ...(await relaysOf(pk).catch(() => []))])]
+ : zapRelays(),
+ });
+ ng = c;
+ ngLoading = null;
+ await c.start();
+ return c;
+ }).catch((e) => { console.warn('chat: relay groups unavailable', e); ngLoading = null; return null; });
+ ngLoading = { pk, p };
+ return p;
+ }
+ function onNg(type, g, data) {
+ if (!ng) return;
+ if (type === 'message') { try { noteEmoji(data); } catch {} }
+ if (g) wnFolds.delete(g.id);
+ bumpMsgRev();
+ scheduleRepaint();
+ }
+ const gClient = (g) => (g && g.nip29 ? ng : g && g.cordn ? cd : wn);
+ const gOn = (g) => (g.nip29 ? onNg : g.cordn ? onCd : onWn);
+ const findGroup = (id) => (wn && wn.groups.get(id)) || (cd && cd.groups.get(id)) || (ng && ng.groups.get(id)) || null;
// One delivered event of a direct group, into that person's thread.
function wnIntoThread(g, peer, m) {
@@ -3886,14 +3922,14 @@ export function messagesFeature(ctx) {
const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]);
const ok = await wn.send(g, inner).catch(() => false);
if (!ok) { toast(t('msgSendFailed')); return; }
- onWn('message', g, inner);
+ gOn(g)('message', g, inner);
}
// ---- groups ----
const wnRead = (id) => 'wn:' + id;
// groups with a screen of their own (a direct group lives in its DM thread)
- const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : [])];
+ const wnGroups = () => [...(wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []), ...(cd ? cd.groups.values() : []), ...(ng ? ng.groups.values() : [])];
function wnTitle(g) {
const c = gClient(g);
@@ -4008,7 +4044,7 @@ export function messagesFeature(ctx) {
const sent = wn.send(g, inner).catch(() => false);
wnFolds.delete(g.id);
render();
- if (!(await sent)) { wnFolds.delete(g.id); toast(t('msgSendFailed')); render(); }
+ if (!(await sent)) { wnFolds.delete(g.id); toast(g.nip29 && g.sendRefused ? t('msgSendFailed') + ': ' + g.sendRefused : t('msgSendFailed')); render(); }
}
async function wnAct(fn) {
@@ -17488,6 +17524,7 @@ export function messagesFeature(ctx) {
// login is asked when it first opens a Cordn group or invites someone).
if (ui.msgView !== 'grp' && cd) cd.focus(null);
cordnChat().then((c) => { if (c && !cdReadied) { cdReadied = true; c.ready(); } });
+ nip29Chat();
// Group chats ride along: the client loads with the wallet, and standing
// on this screen is what makes us invitable (a KeyPackage on the relays).
whiteNoise().then(async (c) => {
@@ -18136,7 +18173,42 @@ export function messagesFeature(ctx) {
// which protocol the group speaks: White Noise (Marmot) or Cordn
h('div', { class: 'seg' },
h('button', { class: proto === 'wn' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'wn'; render(); } }, t('msgGroupProtoWn')),
- h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd'))),
+ h('button', { class: proto === 'cd' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'cd'; render(); } }, t('msgGroupProtoCd')),
+ h('button', { class: proto === 'n29' ? 'active' : '', onClick: () => { ui.msgGroupProto = 'n29'; render(); } }, t('msgGroupProtoN29'))),
+ proto === 'n29' ? nip29JoinPanel() : null,
+ proto === 'n29' ? null : wnNewGroupForm(proto, picks, ready));
+ }
+ // Join a NIP-29 group by its address (they're made in other clients).
+ async function nip29Join() {
+ const raw = (ui.msgN29Addr || '').trim();
+ if (!raw || ui.msgBusy) return;
+ const m = await import('../nip29-client.js');
+ const a = m.parseGroupAddress(raw);
+ if (!a) { toast(t('msgNip29BadAddress')); return; }
+ ui.msgBusy = true; render();
+ try {
+ const c = await nip29Chat();
+ if (!c) throw new Error('no signer');
+ const g = await c.join(a);
+ ui.msgN29Addr = ''; ui.msgHomePanel = null;
+ ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true;
+ if (g.joinRefused) toast(t('msgNip29JoinRefused', { why: g.joinRefused }));
+ } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); }
+ }
+ function nip29JoinPanel() {
+ return h('div', { class: 'col gap6' },
+ h('div', { class: 'small muted' }, t('msgGroupProtoN29Hint')),
+ h('div', { class: 'row gap6' },
+ h('input', {
+ class: 'grow nip29-address', type: 'text', placeholder: t('msgNip29Address'), autocapitalize: 'none', spellcheck: 'false',
+ value: ui.msgN29Addr || '', onInput: (e) => { ui.msgN29Addr = e.target.value; },
+ onKeydown: (e) => { if (e.key === 'Enter') nip29Join(); },
+ }),
+ h('button', { class: 'btn-primary btn-sm', disabled: ui.msgBusy, onClick: nip29Join },
+ ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgJoin'))));
+ }
+ function wnNewGroupForm(proto, picks, ready) {
+ return h('div', { class: 'col gap6' },
proto === 'cd' ? h('div', { class: 'small muted' }, t('msgGroupProtoCdHint')) : null,
h('input', {
type: 'text', placeholder: t('msgGroupName'), maxlength: '64',
@@ -18155,7 +18227,11 @@ export function messagesFeature(ctx) {
const accounts = wn.accounts(g);
const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []);
return h('div', { class: 'col chat-members' },
- h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')),
+ h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, g.nip29 ? t('msgNip29Info', { relay: wn.groupRef(g) }) : t(g.cordn ? 'msgCordnInfo' : 'msgGroupInfo')),
+ g.nip29 && v.about ? h('div', { class: 'small', style: 'padding:2px 12px 6px' }, v.about) : null,
+ g.nip29 ? h('div', { class: 'row gap6', style: 'padding:0 12px 6px' }, h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
+ try { await navigator.clipboard.writeText(wn.groupRef(g)); toast(t('copied')); } catch {}
+ } }, t('msgNip29CopyAddress'))) : null,
...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) },
avatar(pk),
h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' },
@@ -18215,7 +18291,7 @@ export function messagesFeature(ctx) {
!ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => {
close();
const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]);
- if (await wn.send(g, del).catch(() => false)) (g.cordn ? onCd : onWn)('message', g, del);
+ if (await wn.send(g, del).catch(() => false)) gOn(g)('message', g, del);
}) : null));
}
@@ -18223,7 +18299,7 @@ export function messagesFeature(ctx) {
const g = findGroup(ui.msgPeer);
if (!g) {
// the groups are still loading (a reload straight into this screen)
- if (!wn || wnLoading || !cd || cdLoading) { Promise.all([whiteNoise(), cordnChat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); }
+ if (!wn || wnLoading || !cd || cdLoading || !ng || ngLoading) { Promise.all([whiteNoise(), cordnChat(), nip29Chat()]).then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); }
ui.msgView = 'home';
return homeView();
}
@@ -18332,7 +18408,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))),
h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null,
live
- ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn ? null : (f) => wnSendFile(g, f))
+ ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, g.cordn || g.nip29 ? null : (f) => wnSendFile(g, f))
: h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')),
ui.msgSheet ? wnSheet(g) : null);
}