aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/concord.js25
-rw-r--r--src/features/messages.js70
-rw-r--r--src/i18n.js6
-rw-r--r--src/locales/it.json6
-rw-r--r--src/style.css3
5 files changed, 104 insertions, 6 deletions
diff --git a/src/concord.js b/src/concord.js
index 63a83b9..521bfc8 100644
--- a/src/concord.js
+++ b/src/concord.js
@@ -187,6 +187,11 @@ export const foldControl = (entries, { ownerHex, cid }) => {
// entries: [{ rumor, author }] — group by (vsk, eid), take highest intact version
let byEntity = new Map();
for (let e of entries) {
+ // Only the owner's editions are honored below, so only they compete for
+ // a head: anyone holding the control stream's key (every member of a
+ // public community) could otherwise sign a higher version and blank the
+ // owner's ban list, name or channels.
+ if (e.author !== ownerHex) continue;
let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1];
let vsk = tag("vsk"), eid = tag("eid"), ev = +tag("ev");
if (vsk === undefined || !eid || !(ev >= 1)) continue;
@@ -225,6 +230,26 @@ export const foldControl = (entries, { ownerHex, cid }) => {
return state;
};
+// The owner's ban list as it stands: the head edition (its version, and the
+// hash a successor chains to) and the keys on it. Same winner as the fold.
+export const banlistHead = (entries, { ownerHex, cid }) => {
+ let eid = banlistEid(cid), cur = null;
+ for (let e of entries) {
+ if (e.author !== ownerHex || e.rumor.kind !== 3308) continue;
+ let tag = (k) => e.rumor.tags?.find((t) => t[0] === k)?.[1];
+ let ev = +tag("ev");
+ if (tag("vsk") !== "4" || tag("eid") !== eid || !(ev >= 1)) continue;
+ if (!cur || ev > cur.ev || (ev === cur.ev && e.rumor.id < cur.id)) cur = { ev, id: e.rumor.id, ep: tag("ep") || null, content: e.rumor.content };
+ }
+ let list = [];
+ try { let c = cur && JSON.parse(cur.content); if (Array.isArray(c)) list = c.filter((x) => /^[0-9a-f]{64}$/.test(x)); } catch {}
+ return { eid, version: cur ? cur.ev : 0, hash: cur ? editionHash(eid, cur.ev, cur.ep, cur.content) : null, list };
+};
+
+// The next ban-list edition: `list` replaces the one that stands.
+export const makeBanlist = (head, list, nowMs) =>
+ makeEdition({ vsk: 4, eid: head.eid, version: head.version + 1, prev: head.hash || undefined, content: JSON.stringify(list) }, nowMs);
+
// ---- CORD-02 §5 guestbook fold ----
// entries: [{ rumor, author }], returns Map(npub -> { state: 'join'|'leave'|'kick', t })
export const foldGuestbook = (entries, { nowMs, banned = new Set(), ownerHex } = {}) => {
diff --git a/src/features/messages.js b/src/features/messages.js
index 592c5dc..f72d71d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -22,7 +22,7 @@ import {
import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack
import {
channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId,
- foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition,
+ foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, banlistHead, makeBanlist,
communityId, parseInviteLink, makeInviteLink, makeInviteBundleEvent, openInviteBundle,
} from '../concord.js';
import { makeDMRumor, makeDMReaction, unwrapDM, wrapDM } from '../dm.js';
@@ -488,8 +488,9 @@ export function messagesFeature(ctx) {
function roomUnread(room) {
const s = st();
const my = myPubkeys();
+ const banned = room.folded && room.folded.banned;
for (const [id, msgs] of room.byChannel)
- if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > (s.read[chRead(id)] || 0)) return true;
+ if (newestFrom(msgs.values(), (m) => !my.includes(m.author) && !(banned && banned.has(m.author))) > (s.read[chRead(id)] || 0)) return true;
return false;
}
@@ -2017,9 +2018,12 @@ export function messagesFeature(ctx) {
const mine = my.includes(m.author);
const reacts = room.reactions.get(m.rumor.id);
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
- const close = () => { ui.msgSheet = null; ui.emojiPick = null; render(); };
- const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick },
+ const close = () => { ui.msgSheet = null; ui.emojiPick = null; ui.msgBanArm = null; render(); };
+ const item = (icon, label, onClick, cls = '') => h('button', { class: 'msg-sheet-item' + cls, onClick },
h('span', { class: 'msg-sheet-ico' }, icon), label);
+ // the community's admin can take a person out, with everything they said
+ const canBan = !mine && canModerate(room) && m.author !== room.jm.owner;
+ const armed = ui.msgBanArm === m.rumor.id;
return h('div', {
class: 'confirm-pop-backdrop',
onClick: (e) => { if (e.target === e.currentTarget) close(); },
@@ -2036,7 +2040,54 @@ export function messagesFeature(ctx) {
try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {}
close();
}),
- !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null));
+ !ui.emojiPick && mine ? item('✕', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null,
+ // two taps: the first asks, the second does it
+ !ui.emojiPick && canBan ? item('🚫', t(armed ? 'msgBanConfirm' : 'msgBan', { name: displayName(m.author) }), () => {
+ if (!armed) { ui.msgBanArm = m.rumor.id; render(); return; }
+ close(); setBanned(room, m.author, true);
+ }, ' danger' + (armed ? ' armed' : '')) : null));
+ }
+
+ // Removing someone from a community is the owner's ban list (CORD-04 vsk 4):
+ // one owner-signed edition naming every removed key. Every client that
+ // folds the control plane drops those authors' messages, old and new, and
+ // takes them off the member list. Only where we hold the control stream's
+ // signing key (a community founded here, or the built-in one).
+ const canModerate = (room) => !!room.control.sk && myPubkeys().includes(room.jm.owner);
+ let banBusy = false;
+ async function setBanned(room, pk, banned) {
+ if (banBusy) return false;
+ const id = await identity();
+ if (!id || id.pubkey !== room.jm.owner || !room.control.sk) { toast(t('msgOwnerOnly')); return false; }
+ banBusy = true;
+ try {
+ // The new edition replaces the list whole, so it must be built on the
+ // newest one: ask the relays now rather than trust what has streamed
+ // in. A relay that hands back nothing at all (the founding editions
+ // are always there) is not an answer, and we stop.
+ const wraps = await queryOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, 5000).catch(() => []);
+ if (!wraps.length) { toast(t('msgSendFailed')); return false; }
+ const have = new Set(room.controlEntries.map((e) => e.rumor.id));
+ for (const w of wraps) {
+ const o = openWrap(w, room.control);
+ if (o && o.rumor.kind === 3308 && !have.has(o.rumor.id)) { have.add(o.rumor.id); room.controlEntries.push(o); }
+ }
+ const head = banlistHead(room.controlEntries, { ownerHex: room.jm.owner, cid: room.jm.community_id });
+ const list = head.list.filter((x) => x !== pk);
+ if (banned) list.push(pk);
+ if (list.length === head.list.length) { room.scheduleFold(); return true; } // already so
+ const edition = { ...makeBanlist(head, list, Date.now()), pubkey: id.pubkey };
+ const wrap = await wrapRumor(edition, id.signer, room.control, { plaintext: true });
+ if (!(await publishOn(room.relays, wrap))) { toast(t('msgSendFailed')); return false; }
+ room.controlEntries.push({ rumor: rumorWithId(edition), author: id.pubkey });
+ room.scheduleFold();
+ bumpMsgRev();
+ toast(t(banned ? 'msgBanned' : 'msgUnbanned', { name: displayName(pk) }));
+ return true;
+ } catch (e) {
+ toast(t('msgSendFailed'));
+ return false;
+ } finally { banBusy = false; }
}
// The composer's "replying to" context bar, with its way out.
@@ -17889,7 +17940,14 @@ export function messagesFeature(ctx) {
h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' },
h('span', { class: 'chat-name' }, displayName(r.pk)),
r.pk === room.jm.owner ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null),
- h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk))))));
+ h('div', { class: 'muted small' + (r.online ? ' is-online' : '') }, seenLabel(room, r.pk))))),
+ ...(canModerate(room) && room.folded && room.folded.banned.size ? [
+ h('div', { class: 'small muted chat-removed-head' }, t('msgBannedHead')),
+ ...[...room.folded.banned].map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) },
+ h('span', { class: 'ava-wrap' }, avatar(pk)),
+ h('span', { class: 'chat-name grow', style: 'min-width:0' }, displayName(pk)),
+ h('button', { class: 'btn-sm', onClick: (e) => { e.stopPropagation(); setBanned(room, pk, false); } }, t('msgUnban')))),
+ ] : []));
}
function invitePanel(room) {
diff --git a/src/i18n.js b/src/i18n.js
index 06afc11..9c7827d 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -713,6 +713,12 @@ const STR = {
publicChatOpenApp: 'Join the chat',
publicChatLoading: 'Loading messages…',
msgDelete: 'Delete message',
+ msgBan: 'Remove {name} and their messages',
+ msgBanConfirm: 'Remove {name} for everyone? Tap again',
+ msgBanned: '{name} removed',
+ msgUnbanned: '{name} restored',
+ msgBannedHead: 'Removed from this community',
+ msgUnban: 'Restore',
msgNoIdentity: "Your nostr signer isn't connected. Reconnect it to send as yourself",
msgSendFailed: 'No relay accepted the message',
msgLockedChat: 'Your wallet is locked, so it can’t sign messages.',
diff --git a/src/locales/it.json b/src/locales/it.json
index 30b05a8..112296f 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -490,6 +490,12 @@
"publicChatOpenApp": "Partecipa alla chat",
"publicChatLoading": "Caricamento messaggi…",
"msgDelete": "Elimina messaggio",
+ "msgBan": "Rimuovi {name} e i suoi messaggi",
+ "msgBanConfirm": "Rimuovere {name} per tutti? Tocca di nuovo",
+ "msgBanned": "{name} rimosso",
+ "msgUnbanned": "{name} ripristinato",
+ "msgBannedHead": "Rimossi da questa community",
+ "msgUnban": "Ripristina",
"msgNoIdentity": "Il tuo signer nostr non è connesso. Riconnettilo per inviare a tuo nome",
"msgSendFailed": "Nessun relay ha accettato il messaggio",
"msgLockedChat": "Il tuo portafoglio è bloccato, quindi non può firmare i messaggi.",
diff --git a/src/style.css b/src/style.css
index 9c6974b..803979f 100644
--- a/src/style.css
+++ b/src/style.css
@@ -579,6 +579,9 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; }
border: none; background: none; padding: 11px 8px; font-weight: 550; border-radius: 10px;
}
.msg-sheet-item:hover { background: var(--surface2); }
+.msg-sheet-item.danger, .msg-sheet-item.danger .msg-sheet-ico { color: var(--red); }
+.msg-sheet-item.danger.armed { background: color-mix(in srgb, var(--red) 12%, transparent); }
+.chat-removed-head { padding: 10px 4px 2px; }
.msg-sheet-ico { width: 20px; text-align: center; color: var(--muted); flex-shrink: 0; }
/* the quoted message a reply carries, and the composer's replying-to bar */
.chat-quote {