aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js248
-rw-r--r--src/i18n.js3
-rw-r--r--src/style.css4
3 files changed, 220 insertions, 35 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 0d4933e..3b3857d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -29,6 +29,7 @@ import { getNetwork } from '../api.js';
import { decodeBolt11 } from '../ark/lightning.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
import { sha256 } from '@noble/hashes/sha256';
+import { base64urlnopad } from '@scure/base';
import { t } from '../i18n.js';
import { SIGNER_SILENT } from '../dm.js';
@@ -996,6 +997,36 @@ export function messagesFeature(ctx) {
// tapping an existing chip joins that vote. Optimistic like sendMessage:
// the chip appears immediately, the wire catches up.
const REACT_EMOJIS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ”ฅ', '๐ŸŽ‰', '๐Ÿ™'];
+ // The full picker behind the quick row: a searchable grid. Names are the
+ // search terms; the list is curated rather than the whole Unicode table,
+ // which is plenty for a reaction and keeps the bundle small.
+ const EMOJI_LIB = `๐Ÿ‘€ eyes seen look|๐Ÿ‘ thumbs up like yes|๐Ÿ‘Ž thumbs down no|โค๏ธ heart love red|๐Ÿงก orange heart|๐Ÿ’› yellow heart|๐Ÿ’š green heart|๐Ÿ’™ blue heart|๐Ÿ’œ purple heart|๐Ÿ–ค black heart|๐Ÿค white heart|๐Ÿ’” broken heart|โค๏ธโ€๐Ÿ”ฅ heart fire|๐Ÿ’ฏ 100 hundred|๐Ÿ˜‚ laugh joy tears|๐Ÿคฃ rofl laughing|๐Ÿ˜€ grin smile|๐Ÿ˜„ smile happy|๐Ÿ˜ beaming grin|๐Ÿ˜… sweat smile|๐Ÿ˜Š blush smile|๐Ÿ™‚ slight smile|๐Ÿ˜‰ wink|๐Ÿ˜ heart eyes love|๐Ÿฅฐ smiling hearts love|๐Ÿ˜˜ kiss blow|๐Ÿ˜— kiss|๐Ÿคฉ star struck wow|๐Ÿฅณ party celebrate|๐Ÿ˜Ž cool sunglasses|๐Ÿค“ nerd glasses|๐Ÿง monocle thinking|๐Ÿค” thinking hmm|๐Ÿคจ raised eyebrow skeptical|๐Ÿ˜ neutral meh|๐Ÿ˜‘ expressionless|๐Ÿ˜ถ no mouth silent|๐Ÿ™„ eye roll|๐Ÿ˜ smirk|๐Ÿ˜’ unamused|๐Ÿ˜ž disappointed sad|๐Ÿ˜” pensive sad|๐Ÿ˜Ÿ worried|๐Ÿ˜• confused|๐Ÿ™ frown sad|โ˜น๏ธ frowning sad|๐Ÿ˜ข cry tear sad|๐Ÿ˜ญ sob crying|๐Ÿ˜ค huff steam angry|๐Ÿ˜  angry|๐Ÿ˜ก rage furious|๐Ÿคฌ swearing cursing|๐Ÿคฏ mind blown exploding|๐Ÿ˜ณ flushed embarrassed|๐Ÿฅบ pleading puppy eyes|๐Ÿ˜ฑ scream fear|๐Ÿ˜จ fearful|๐Ÿ˜ฐ anxious sweat|๐Ÿ˜ฅ sad relieved|๐Ÿ˜“ downcast sweat|๐Ÿค— hug|๐Ÿคญ hand over mouth giggle|๐Ÿคซ shush quiet|๐Ÿคฅ lying|๐Ÿ˜ฌ grimace awkward|๐Ÿ˜ด sleeping zzz|๐Ÿคค drooling|๐Ÿ˜ช sleepy|๐Ÿ˜ท mask sick|๐Ÿค’ thermometer sick|๐Ÿค• bandage hurt|๐Ÿคข nauseated|๐Ÿคฎ vomit|๐Ÿคง sneeze|๐Ÿฅต hot|๐Ÿฅถ cold freezing|๐Ÿฅด woozy|๐Ÿ˜ต dizzy|๐Ÿ˜ตโ€๐Ÿ’ซ spiral dizzy|๐Ÿค  cowboy|๐Ÿฅธ disguise|๐Ÿ˜‡ halo angel innocent|๐Ÿคก clown|๐Ÿ‘ป ghost|๐Ÿ’€ skull dead|โ˜ ๏ธ skull crossbones|๐Ÿ‘ฝ alien|๐Ÿค– robot|๐Ÿ’ฉ poop|๐Ÿ™ˆ see no evil monkey|๐Ÿ™‰ hear no evil|๐Ÿ™Š speak no evil|๐Ÿ‘‹ wave hello bye|๐Ÿคš raised back hand|โœ‹ raised hand stop|๐Ÿ–๏ธ hand fingers|๐Ÿ–– vulcan spock|๐Ÿ‘Œ ok perfect|๐ŸคŒ pinched fingers italian|๐Ÿค pinch small|โœŒ๏ธ peace victory|๐Ÿคž fingers crossed luck|๐ŸคŸ love you gesture|๐Ÿค˜ rock horns|๐Ÿค™ call me shaka|๐Ÿ‘ˆ point left|๐Ÿ‘‰ point right|๐Ÿ‘† point up|๐Ÿ‘‡ point down|โ˜๏ธ index up|๐Ÿ‘Š fist punch|โœŠ raised fist|๐Ÿค› left fist|๐Ÿคœ right fist|๐Ÿ‘ clap applause|๐Ÿ™Œ raised hands hooray|๐Ÿคฒ palms up|๐Ÿค handshake deal|๐Ÿ™ pray thanks please|๐Ÿ’ช muscle strong flex|๐Ÿซก salute|๐Ÿซถ heart hands|๐Ÿง  brain smart|๐Ÿ‘‘ crown king|๐ŸŽฉ top hat|๐Ÿ”ฅ fire lit hot|โœจ sparkles|โญ star|๐ŸŒŸ glowing star|๐Ÿ’ซ dizzy star|โšก lightning zap bolt|๐Ÿ’ฅ boom collision|๐Ÿ’ข anger|๐Ÿ’ฆ sweat drops|๐Ÿ’จ dash wind|๐ŸŽ‰ party popper tada|๐ŸŽŠ confetti|๐ŸŽˆ balloon|๐ŸŽ gift present|๐Ÿ† trophy win|๐Ÿฅ‡ gold medal first|๐ŸŽฏ bullseye target|๐Ÿš€ rocket launch moon|๐Ÿ›ธ ufo|๐ŸŒ™ moon|โ˜€๏ธ sun|๐ŸŒˆ rainbow|โ˜• coffee|๐Ÿบ beer|๐Ÿป cheers beers|๐Ÿฅ‚ clink champagne|๐Ÿพ champagne bottle|๐Ÿ• pizza|๐Ÿ” burger|๐Ÿฟ popcorn|๐ŸŽ‚ cake birthday|๐Ÿฐ cake slice|๐Ÿฉ donut|๐Ÿช cookie|๐ŸŽ apple|๐Ÿฅ‘ avocado|๐ŸŒถ๏ธ hot pepper spicy|๐Ÿง‚ salt|๐Ÿ goat|๐Ÿถ dog|๐Ÿฑ cat|๐Ÿธ frog pepe|๐Ÿต monkey|๐Ÿฆ„ unicorn|๐Ÿ bee|๐Ÿฆ‹ butterfly|๐Ÿข turtle slow|๐Ÿ snake|๐Ÿฆ€ crab|๐Ÿ‹ whale|๐Ÿฆˆ shark|๐Ÿฆ… eagle|๐Ÿ” chicken|๐Ÿท pig|๐Ÿฎ cow|๐Ÿป bear|๐Ÿผ panda|๐Ÿจ koala|๐Ÿฆ lion|๐Ÿฏ tiger|๐ŸฆŠ fox|๐Ÿบ wolf|๐Ÿ‰ dragon|๐ŸŒน rose|๐ŸŒป sunflower|๐ŸŒฑ seedling grow|๐ŸŒฒ tree|๐Ÿ€ clover luck|๐Ÿ„ mushroom|๐Ÿ’Ž gem diamond|๐Ÿ’ฐ money bag|๐Ÿ’ธ money wings|๐Ÿ’ต dollar cash|๐Ÿช™ coin|โ‚ฟ bitcoin btc|๐Ÿฆ bank|๐Ÿ“ˆ chart up stonks|๐Ÿ“‰ chart down|๐Ÿ”‘ key|๐Ÿ”’ lock|๐Ÿ”“ unlock|๐Ÿ›ก๏ธ shield|โš”๏ธ swords|๐Ÿ—ก๏ธ dagger|๐Ÿ”จ hammer|๐Ÿ”ง wrench|โš™๏ธ gear settings|๐Ÿงฒ magnet|๐Ÿ’ก idea bulb|๐Ÿ”ฆ flashlight|๐Ÿ”‹ battery|๐Ÿ“ฑ phone|๐Ÿ’ป laptop|๐Ÿ–ฅ๏ธ desktop|โŒจ๏ธ keyboard|๐Ÿ–จ๏ธ printer|๐Ÿ“ท camera|๐Ÿ“ธ flash camera|๐ŸŽฅ movie camera|๐ŸŽฌ clapper|๐ŸŽต music note|๐ŸŽถ notes music|๐ŸŽค microphone|๐ŸŽง headphones|๐ŸŽธ guitar|๐ŸŽฎ game controller|๐ŸŽฒ dice|๐Ÿงฉ puzzle|โ™Ÿ๏ธ chess|๐Ÿ€ basketball|โšฝ soccer football|๐Ÿˆ football|โšพ baseball|๐ŸŽพ tennis|๐Ÿ volleyball|๐Ÿ“ ping pong|๐ŸฅŠ boxing|๐Ÿ„ surf|๐Ÿšด bike|๐Ÿƒ run|๐Ÿง˜ yoga meditate|๐Ÿ›Œ bed sleep|๐Ÿš— car|๐Ÿš• taxi|๐ŸšŒ bus|๐Ÿš‚ train|โœˆ๏ธ plane travel|โ›ต sailboat|๐Ÿšข ship|๐Ÿ  house home|๐Ÿข office|๐Ÿ—๏ธ construction crane|๐Ÿ—ฝ statue liberty|๐Ÿ—ผ tower|๐Ÿ”๏ธ mountain|๐Ÿ–๏ธ beach|๐ŸŒ earth globe world|๐Ÿ—บ๏ธ map|๐Ÿงญ compass|โฐ alarm clock|โณ hourglass|โŒ› hourglass done|๐Ÿ“… calendar|๐Ÿ“Œ pin|๐Ÿ“Ž paperclip|โœ‚๏ธ scissors|๐Ÿ“ memo note|๐Ÿ“š books|๐Ÿ“– book|๐Ÿ“ฐ newspaper|โœ‰๏ธ envelope mail|๐Ÿ“ฌ mailbox|๐Ÿ“ฆ package box|๐Ÿท๏ธ label tag|๐Ÿ”” bell notification|๐Ÿ”• bell off mute|๐Ÿ“ฃ megaphone|๐Ÿ“ข loudspeaker|๐Ÿ’ฌ speech bubble|๐Ÿ’ญ thought bubble|๐Ÿ—ฏ๏ธ anger bubble|โœ… check yes done|โŒ cross no wrong|โ“ question|โ— exclamation|โ€ผ๏ธ double exclamation|โ‰๏ธ interrobang|โš ๏ธ warning|๐Ÿšซ prohibited|โ™ป๏ธ recycle|โœ”๏ธ check mark|โž• plus|โž– minus|โž— divide|โœ–๏ธ multiply|๐Ÿ” repeat|๐Ÿ”€ shuffle|โ–ถ๏ธ play|โธ๏ธ pause|โน๏ธ stop|โฉ fast forward|โช rewind|๐Ÿ” top|๐Ÿ†• new|๐Ÿ†’ cool|๐Ÿ†“ free|๐Ÿ†— ok|๐Ÿ”ž 18 adult|๐Ÿ…ฐ๏ธ a|๐Ÿ…ฑ๏ธ b|๐Ÿ†Ž ab|๐Ÿ…พ๏ธ o|๐Ÿ”ด red circle|๐ŸŸ  orange circle|๐ŸŸก yellow circle|๐ŸŸข green circle|๐Ÿ”ต blue circle|๐ŸŸฃ purple circle|โšซ black circle|โšช white circle|๐ŸŸฅ red square|๐ŸŸง orange square|๐ŸŸจ yellow square|๐ŸŸฉ green square|๐ŸŸฆ blue square|๐ŸŸช purple square|โฌ› black square|โฌœ white square|๐Ÿ checkered flag finish|๐Ÿšฉ red flag|๐Ÿณ๏ธ white flag|๐Ÿดโ€โ˜ ๏ธ pirate flag|๐Ÿ‡จ๐Ÿ‡ฆ canada|๐Ÿ‡บ๐Ÿ‡ธ usa america|๐Ÿ‡ฌ๐Ÿ‡ง uk britain|๐Ÿ‡ช๐Ÿ‡บ eu europe|๐Ÿ‡ฏ๐Ÿ‡ต japan|๐Ÿ‡ง๐Ÿ‡ท brazil|๐Ÿ‡ฉ๐Ÿ‡ช germany|๐Ÿ‡ซ๐Ÿ‡ท france|๐Ÿ‡ฎ๐Ÿ‡น italy|๐Ÿ‡ช๐Ÿ‡ธ spain|๐Ÿ‡ฒ๐Ÿ‡ฝ mexico|๐Ÿ‡ฆ๐Ÿ‡บ australia|๐Ÿ‡ธ๐Ÿ‡ป el salvador|๐Ÿ‡ฆ๐Ÿ‡ท argentina|๐Ÿ‡ณ๐Ÿ‡ฌ nigeria|๐Ÿ‡ฎ๐Ÿ‡ณ india|๐Ÿ‡จ๐Ÿ‡ณ china|๐Ÿ‡ฐ๐Ÿ‡ท korea|๐Ÿ‡ท๐Ÿ‡บ russia|๐Ÿ‡บ๐Ÿ‡ฆ ukraine|๐Ÿ‡ต๐Ÿ‡น portugal|๐Ÿ‡ณ๐Ÿ‡ฑ netherlands|๐Ÿ‡ธ๐Ÿ‡ช sweden|๐Ÿ‡จ๐Ÿ‡ญ switzerland`
+ .split('|').map((x) => { const i = x.indexOf(' '); return { e: x.slice(0, i), k: x.slice(i + 1) }; });
+ const RECENT_KEY = 'btc-wallet-recent-emoji';
+ const recentEmojis = () => { try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch { return []; } };
+ const noteRecent = (e) => { try { localStorage.setItem(RECENT_KEY, JSON.stringify([e, ...recentEmojis().filter((x) => x !== e)].slice(0, 24))); } catch {} };
+ // The reaction row for a sheet: recents lead the quick row, and the last
+ // button opens the full picker in place โ€” a search box and the grid.
+ function reactRow(myReact, onPick) {
+ const pick = (e) => { ui.emojiPick = null; noteRecent(e); onPick(e); };
+ if (ui.emojiPick) {
+ const q = (ui.emojiPick.q || '').trim().toLowerCase();
+ const hits = q ? EMOJI_LIB.filter((x) => x.k.includes(q) || x.e === q) : [...new Set([...recentEmojis(), ...EMOJI_LIB.map((x) => x.e)])].map((e) => ({ e }));
+ return h('div', { class: 'col', style: 'gap:8px' },
+ h('input', {
+ type: 'text', placeholder: t('msgEmojiSearch'), value: ui.emojiPick.q || '', autofocus: true,
+ onInput: (e) => { ui.emojiPick.q = e.target.value; render(); },
+ onKeydown: (e) => { if (e.key === 'Enter' && hits.length) pick(hits[0].e); if (e.key === 'Escape') { ui.emojiPick = null; render(); } },
+ }),
+ h('div', { class: 'emoji-grid' },
+ hits.slice(0, 160).map((x) => h('button', { class: x.e === myReact ? 'on' : '', title: x.k || '', onClick: () => pick(x.e) }, x.e)),
+ !hits.length ? h('div', { class: 'small muted', style: 'padding:8px' }, t('msgEmojiNone')) : null));
+ }
+ const quick = [...new Set([...recentEmojis(), ...REACT_EMOJIS])].slice(0, 6);
+ return h('div', { class: 'msg-sheet-emojis' },
+ quick.map((e2) => h('button', { class: e2 === myReact ? 'on' : '', onClick: () => pick(e2) }, e2)),
+ h('button', { class: 'more', title: t('msgEmojiMore'), onClick: () => { ui.emojiPick = { q: '' }; render(); } }, '๏ผ‹'));
+ }
async function sendReaction(room, chId, m, emoji) {
const id = await identity();
if (!id) { noIdToast(); return; }
@@ -1052,7 +1083,7 @@ 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; render(); };
+ const close = () => { ui.msgSheet = null; ui.emojiPick = null; render(); };
const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick },
h('span', { class: 'msg-sheet-ico' }, icon), label);
return h('div', {
@@ -1060,22 +1091,18 @@ export function messagesFeature(ctx) {
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
- h('div', { class: 'msg-sheet-emojis' },
- REACT_EMOJIS.map((e2) => h('button', {
- class: e2 === myReact ? 'on' : '',
- onClick: () => { close(); sendReaction(room, chId, m, e2); },
- }, e2))),
- item('โ†ฉ', t('msgReply'), () => {
+ reactRow(myReact, (e2) => { close(); sendReaction(room, chId, m, e2); }),
+ ui.emojiPick ? null : item('โ†ฉ', t('msgReply'), () => {
ui.msgReplyTo = m.rumor.id;
close();
setTimeout(() => document.getElementById('msg-draft')?.focus(), 50);
}),
- !mine && canZapPk(m.author) ? item('โšก', t('msgZap'), () => { close(); zapMessage(m.author, m.rumor.id); }) : null,
- item('โง‰', t('copy'), async () => {
+ !ui.emojiPick && !mine && canZapPk(m.author) ? item('โšก', t('msgZap'), () => { close(); zapMessage(m.author, m.rumor.id); }) : null,
+ ui.emojiPick ? null : item('โง‰', t('copy'), async () => {
try { await navigator.clipboard.writeText(msgSnippet(room, m, 100000)); toast(t('copied')); } catch {}
close();
}),
- mine ? item('โœ•', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null));
+ !ui.emojiPick && mine ? item('โœ•', t('msgDelete'), () => { close(); deleteMessage(room, chId, m); }) : null));
}
// The composer's "replying to" context bar, with its way out.
@@ -1177,28 +1204,45 @@ export function messagesFeature(ctx) {
community_id: room.jm.community_id, owner: room.jm.owner, owner_salt: room.jm.owner_salt,
community_root: room.jm.community_root, root_epoch: room.jm.root_epoch || 0,
...(room.jm.control_pk ? { control_pk: room.jm.control_pk } : {}),
- channels: roomChannels(room).map((c) => {
- const held = (room.jm.channels || []).find((h) => h.id === c.id);
- return held && channelIsPrivate(room.jm, held) ? { id: c.id, name: c.name, key: held.key, epoch: held.epoch || 0 } : { id: c.id, name: c.name };
- }),
+ channels: channelList({ ...room.jm, channels: roomChannels(room).map((c) => (room.jm.channels || []).find((h) => h.id === c.id) || c) }),
relays: room.relays, name: (room.folded?.metadata?.name) || room.jm.name,
creator_npub: creatorPk,
};
}
+ // Bundles minted before channel entries carried key + epoch never opened in
+ // Vector. The link itself is fine (signer + token), so re-sign the bundle at
+ // the same coordinate with the current shape; the relay replaces it. Once
+ // per invite per device, and again whenever the shape changes.
+ const BUNDLE_REV = 2;
+ async function healInviteBundles() {
+ const s = st();
+ for (const [cid, inv] of Object.entries(s.invites || {})) {
+ if (inv.rev === BUNDLE_REV || !inv.sk || !inv.token) continue;
+ const jm = communityById(cid);
+ if (!jm) continue;
+ try {
+ const room = ensureRoom(jm, { subscribe: false });
+ const creator = inv.creator || (await identity())?.pubkey;
+ const evt = makeInviteBundleEvent(hexToBytes(inv.sk), bundleFor(room, creator), hexToBytes(inv.token));
+ if (await publishOn(room.relays, evt)) { inv.rev = BUNDLE_REV; save(s); }
+ } catch {}
+ }
+ }
+
async function mintInviteLink(room) {
const id = await identity();
if (!id) { noIdToast(); return null; }
const s = st();
const existing = s.invites[room.jm.community_id];
- if (existing) return existing.url;
+ if (existing) { if (existing.rev !== BUNDLE_REV) healInviteBundles().catch(() => {}); return existing.url; }
const linkSk = generateSecretKey();
const token = crypto.getRandomValues(new Uint8Array(16));
const evt = makeInviteBundleEvent(linkSk, bundleFor(room, id.pubkey), token);
const ok = await publishOn(room.relays, evt);
if (!ok) { toast(t('msgSendFailed')); return null; }
const url = makeInviteLink(APP_BASE, getPublicKey(linkSk), room.relays, token);
- s.invites[room.jm.community_id] = { sk: bytesToHex(linkSk), token: bytesToHex(token), url, created_at: Math.floor(Date.now() / 1000) };
+ s.invites[room.jm.community_id] = { sk: bytesToHex(linkSk), token: bytesToHex(token), url, created_at: Math.floor(Date.now() / 1000), rev: BUNDLE_REV, creator: id.pubkey };
save(s);
publishLists();
return url;
@@ -1594,8 +1638,13 @@ export function messagesFeature(ctx) {
// The channel entries of join material: id + name, plus key + epoch for a
// private channel (a public one derives from the root, so nothing to carry).
+ // Every entry carries key + epoch on the wire (CORD-05 ยง1 `{id, key, epoch,
+ // name}`): a public channel's key IS the community_root at the base epoch.
+ // Vector's parser has no default for either field, so an entry without them
+ // fails its whole bundle โ€” which is why our invite links never opened there.
const channelList = (b) => (b.channels || []).slice(0, 256).map((c) =>
- channelIsPrivate(b, c) ? { id: c.id, name: c.name, key: c.key, epoch: c.epoch || 0 } : { id: c.id, name: c.name });
+ channelIsPrivate(b, c) ? { id: c.id, name: c.name, key: c.key, epoch: c.epoch || 0 }
+ : { id: c.id, name: c.name, key: b.community_root, epoch: b.root_epoch || 0 });
// Join material subset (never the icon, never link fields). We don't
// Refound ourselves, so seed and current coincide.
@@ -1680,6 +1729,7 @@ export function messagesFeature(ctx) {
async function publishLists() {
const ids = await selfCryptors();
const s = st();
+ publishFragments(ids, fragAt).catch(() => {});
const docs = [[13302, buildCommunityList(s)], [13303, buildInviteList(s)]];
for (const id of ids)
for (const [kind, doc] of docs) {
@@ -1692,15 +1742,143 @@ export function messagesFeature(ctx) {
}
}
+ // ---- CORD-02 ยง8 fragmented Community List (kind 33302) ----
+ // The form Vector and the other Concord clients write: addressable, one
+ // event per fragment (d = index, every fragment declares the total), the
+ // content NIP-44-encrypted to self, every 32-byte value unpadded base64url,
+ // an entry's embedded join material carrying no community_id (it inherits
+ // the entry's) and no seed when it equals current. Reading it is what lets
+ // a community joined in Vector under the same key show up here; writing it
+ // is what lets Vector see one joined here. The retired single-event 13302
+ // is still read and written for older coinos devices.
+ const LIST_FRAG_KIND = 33302;
+ const STOCK_RELAYS = ['wss://jskitty.com/nostr', 'wss://asia.vectorapp.io/nostr', 'wss://relay.ditto.pub', 'wss://relay.dreamith.to'];
+ const LIST_FRAG_BYTES = 48 * 1024;
+ const b64of = (v) => (/^[0-9a-f]{64}$/i.test(v || '') ? base64urlnopad.encode(hexToBytes(v.toLowerCase())) : v);
+ const hexOf = (v) => {
+ if (typeof v !== 'string' || v.length !== 43) return v;
+ try { const b = base64urlnopad.decode(v); return b.length === 32 ? bytesToHex(b) : v; } catch { return v; }
+ };
+ // where a list can live: our relays, the stock CORD set (where Vector's
+ // lands when its own relays refuse the kind), and every held community's
+ const listRelays = () => [...new Set([...DM_RELAYS, ...STOCK_RELAYS, ...communities().flatMap((jm) => jm.relays || [])])].slice(0, 12);
+ const fragMaterial = (jm) => {
+ const m = jmSubset(jm);
+ return {
+ owner: b64of(m.owner), owner_salt: b64of(m.owner_salt), community_root: b64of(m.community_root), root_epoch: m.root_epoch || 0,
+ ...(m.control_pk ? { control_pk: b64of(m.control_pk) } : {}),
+ channels: (m.channels || []).map((c) => ({ id: b64of(c.id), key: b64of(c.key || m.community_root), epoch: c.epoch || 0, name: c.name })),
+ relays: m.relays || [], name: m.name,
+ };
+ };
+ const buildFragments = (s) => {
+ const entries = s.communities.filter((jm) => (s.tombstones[jm.community_id] || 0) <= (jm.added_at || 0))
+ .map((jm) => ({ community_id: b64of(jm.community_id), current: fragMaterial(jm), added_at: jm.added_at || 0 }));
+ const tombstones = Object.entries(s.tombstones).map(([community_id, removed_at]) => ({ community_id: b64of(community_id), removed_at }));
+ const frags = [{ frags: 1, entries: [], tombstones: [] }];
+ const size = (f) => JSON.stringify(f).length;
+ for (const e of entries) {
+ const last = frags[frags.length - 1];
+ if (!last.entries.length || size(last) + JSON.stringify(e).length < LIST_FRAG_BYTES) last.entries.push(e);
+ else frags.push({ frags: 1, entries: [e], tombstones: [] });
+ }
+ for (const t of tombstones) {
+ const last = frags[frags.length - 1];
+ if (!last.tombstones.length || size(last) + JSON.stringify(t).length < LIST_FRAG_BYTES) last.tombstones.push(t);
+ else frags.push({ frags: 1, entries: [], tombstones: [t] });
+ }
+ for (const f of frags) f.frags = frags.length;
+ return frags;
+ };
+ const unmaterial = (m, cid) => ({
+ community_id: cid, owner: hexOf(m.owner), owner_salt: hexOf(m.owner_salt), community_root: hexOf(m.community_root),
+ root_epoch: Number(m.root_epoch) || 0, ...(m.control_pk ? { control_pk: hexOf(m.control_pk) } : {}),
+ channels: (m.channels || []).map((c) => ({ id: hexOf(c.id), ...(c.key ? { key: hexOf(c.key) } : {}), epoch: Number(c.epoch) || 0, name: c.name })),
+ relays: Array.isArray(m.relays) ? m.relays : [], name: m.name || '',
+ });
+ // the fragments a reader holds, unioned into the 13302-shaped document
+ const defragment = (frags) => {
+ const doc = { entries: [], tombstones: [] };
+ for (const f of frags) {
+ for (const e of f.entries || []) {
+ if (!e || !e.current) continue;
+ const cid = hexOf(e.community_id);
+ const current = unmaterial(e.current, cid);
+ doc.entries.push({ community_id: cid, seed: e.seed ? unmaterial(e.seed, cid) : current, current, added_at: Number(e.added_at) || 0 });
+ }
+ for (const t of f.tombstones || []) doc.tombstones.push({ community_id: hexOf(t.community_id), removed_at: Number(t.removed_at) || 0 });
+ }
+ return doc;
+ };
+ // Fetch one identity's fragments: newest per index wins (an age tie falls to
+ // the lower id), the newest fragment's declared total governs, and each
+ // index's created_at is kept so a rewrite can exceed it.
+ async function readFragments(id, evs) {
+ const newest = new Map();
+ for (const e of evs) {
+ if (e.kind !== LIST_FRAG_KIND || e.pubkey !== id.pk) continue;
+ const idx = parseInt(e.tags.find((t) => t[0] === 'd')?.[1], 10);
+ if (!(idx >= 0)) continue;
+ const cur = newest.get(idx);
+ if (!cur || e.created_at > cur.created_at || (e.created_at === cur.created_at && e.id < cur.id)) newest.set(idx, e);
+ }
+ const frags = new Map(); const createdAt = {};
+ for (const [idx, e] of newest) {
+ try { const f = JSON.parse(await id.dec(e.content)); if (f && typeof f === 'object') { frags.set(idx, f); createdAt[idx] = e.created_at; } } catch {}
+ }
+ if (!frags.size) return null;
+ let top = null; for (const [idx, f] of frags) if (!top || createdAt[idx] > createdAt[top] || (createdAt[idx] === createdAt[top] && (f.frags || 1) > (frags.get(top).frags || 1))) top = idx;
+ const declared = Math.max(1, Number(frags.get(top).frags) || 1);
+ const live = [...frags.entries()].filter(([idx]) => idx < declared).map(([, f]) => f);
+ return { doc: defragment(live), createdAt, declared, complete: live.length === declared };
+ }
+ async function publishFragments(ids, prevAt = {}) {
+ const s = st();
+ const frags = buildFragments(s);
+ const now = Math.floor(Date.now() / 1000);
+ for (const id of ids)
+ for (let i = 0; i < frags.length; i++) {
+ try {
+ const created_at = Math.max(now, ((prevAt[id.pk] || {})[i] || 0) + 1);
+ const evt = await id.sign({ kind: LIST_FRAG_KIND, tags: [['d', String(i)]], content: await id.enc(JSON.stringify(frags[i])), created_at });
+ publishOn(listRelays(), evt);
+ } catch {}
+ }
+ }
+
let listsSynced = false;
- async function syncLists() {
- if (listsSynced) return;
+ let listsSyncedAt = 0;
+ let listLiveSub = null;
+ const fragAt = {}; // pk -> { index: created_at } as last read
+ // Re-sync is cheap and a join made in another client should show up soon
+ // after: the home view asks again after a minute, and a live subscription
+ // on the fragment kind brings a fresh write straight in.
+ async function syncLists({ force = false } = {}) {
+ if (listsSynced && !force) return;
const ids = await selfCryptors();
if (!ids.length) return;
- listsSynced = true;
- const evs = await queryOn(DM_RELAYS, { kinds: [13302, 13303], authors: ids.map((i) => i.pk) }, 3500);
+ listsSynced = true; listsSyncedAt = Date.now();
+ const authors = ids.map((i) => i.pk);
+ const [evs, fragEvs] = await Promise.all([
+ queryOn(DM_RELAYS, { kinds: [13302, 13303], authors }, 3500),
+ queryOn(listRelays(), { kinds: [LIST_FRAG_KIND], authors }, 4500),
+ ]);
+ if (!listLiveSub) {
+ listLiveSub = subscribeOn(listRelays(), { kinds: [LIST_FRAG_KIND], authors, since: Math.floor(Date.now() / 1000) }, () => {
+ clearTimeout(listLiveSub.t); listLiveSub.t = setTimeout(() => syncLists({ force: true }).catch(() => {}), 1500);
+ });
+ allUnsubs.push(() => { try { listLiveSub(); } catch {} listLiveSub = null; });
+ }
let changed = false;
const remoteDocs = new Set();
+ const remoteLive = new Set(); let anyFrag = false;
+ for (const id of ids) {
+ const got = await readFragments(id, fragEvs);
+ if (!got) continue;
+ anyFrag = true; fragAt[id.pk] = got.createdAt;
+ for (const e of got.doc.entries) if (!(got.doc.tombstones.find((t) => t.community_id === e.community_id)?.removed_at > e.added_at)) remoteLive.add(e.community_id);
+ changed = mergeCommunityList(got.doc) || changed;
+ }
for (const kind of [13302, 13303])
for (const id of ids) {
const newest = evs.filter((e) => e.kind === kind && e.pubkey === id.pk).sort((a, b) => b.created_at - a.created_at)[0];
@@ -1713,6 +1891,7 @@ export function messagesFeature(ctx) {
else changed = mergeInviteList(doc) || changed;
} catch {}
}
+ healInviteBundles().catch(() => {});
if (changed) {
// cache-only here too โ€” subscription waits for the user to open Chat
for (const jm of communities()) ensureRoom(jm, { subscribe: ui.chatOpen });
@@ -1725,6 +1904,11 @@ export function messagesFeature(ctx) {
|| current.some(([kind, doc]) => !remoteDocs.has(kind + ':' + doc));
if (anyMissing && (s.communities.length || Object.keys(s.invites).length || Object.keys(s.tombstones).length))
publishLists();
+ // the fragmented form: write it when no copy exists, or ours knows a
+ // membership (or a leave) the relay copy doesn't
+ const localLive = s.communities.filter((jm) => (s.tombstones[jm.community_id] || 0) <= (jm.added_at || 0)).map((jm) => jm.community_id);
+ const fragStale = !anyFrag || localLive.some((cid) => !remoteLive.has(cid)) || [...remoteLive].some((cid) => !localLive.includes(cid) && s.tombstones[cid]);
+ if (fragStale && (localLive.length || Object.keys(s.tombstones).length)) publishFragments(ids, fragAt);
}
async function leaveCommunity(room) {
@@ -3742,7 +3926,7 @@ export function messagesFeature(ctx) {
function noteSheet() {
if (!ui.noteSheet) return null;
const ev = ui.noteSheet;
- const close = () => { ui.noteSheet = null; render(); };
+ const close = () => { ui.noteSheet = null; ui.emojiPick = null; render(); };
const mine = isMe(ev.pubkey);
const item = (icon, label, onClick, danger) => h('button', {
class: 'btn-block', style: 'text-align:left' + (danger ? ';color:var(--red,#c0392b)' : ''),
@@ -3867,10 +4051,7 @@ export function messagesFeature(ctx) {
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
- h('div', { class: 'msg-sheet-emojis' },
- REACT_EMOJIS.map((e2) => h('button', {
- onClick: () => { close(); reactTo(ev, e2).catch(() => {}); },
- }, e2))),
+ reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}); }),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
}
@@ -4968,6 +5149,7 @@ export function messagesFeature(ctx) {
// Threads you've since replied to should stop being strangers to the
// worker; throttled inside, so this is cheap on every render.
syncInbox().catch(() => {});
+ if (Date.now() - listsSyncedAt > 60_000) syncLists({ force: true }).catch(() => {});
for (const jm of communities()) ensureRoom(jm);
for (const peer of threads.keys()) profileOf(peer); // names and faces, in one batch
@@ -5434,7 +5616,7 @@ export function messagesFeature(ctx) {
const my = myPubkeys();
const reacts = dmReacts.get(m.rumor.id);
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
- const close = () => { ui.msgSheet = null; render(); };
+ const close = () => { ui.msgSheet = null; ui.emojiPick = null; render(); };
const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick },
h('span', { class: 'msg-sheet-ico' }, icon), label);
return h('div', {
@@ -5442,18 +5624,14 @@ export function messagesFeature(ctx) {
onClick: (e) => { if (e.target === e.currentTarget) close(); },
},
h('div', { class: 'card col msg-sheet' },
- h('div', { class: 'msg-sheet-emojis' },
- REACT_EMOJIS.map((e2) => h('button', {
- class: e2 === myReact ? 'on' : '',
- onClick: () => { close(); sendDmReaction(peer, m, e2); },
- }, e2))),
- item('โ†ฉ', t('msgReply'), () => {
+ reactRow(myReact, (e2) => { close(); sendDmReaction(peer, m, e2); }),
+ ui.emojiPick ? null : item('โ†ฉ', t('msgReply'), () => {
ui.msgReplyTo = m.rumor.id;
close();
setTimeout(() => document.getElementById('msg-draft')?.focus(), 50);
}),
- !m.mine && canZapPk(m.rumor.pubkey) ? item('โšก', t('msgZap'), () => { close(); zapMessage(m.rumor.pubkey, m.rumor.id); }) : null,
- item('โง‰', t('copy'), async () => {
+ !ui.emojiPick && !m.mine && canZapPk(m.rumor.pubkey) ? item('โšก', t('msgZap'), () => { close(); zapMessage(m.rumor.pubkey, m.rumor.id); }) : null,
+ ui.emojiPick ? null : item('โง‰', t('copy'), async () => {
try { await navigator.clipboard.writeText(m.rumor.content); toast(t('copied')); } catch {}
close();
})));
diff --git a/src/i18n.js b/src/i18n.js
index a44f5c4..6712c4b 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -198,6 +198,9 @@ const STR = {
msgAttachTooBig: 'That file is too big (20 MB max)',
msgUploadFailed: 'Upload failed โ€” try again',
msgPhoto: 'Photo',
+ msgEmojiSearch: 'Search emojiโ€ฆ',
+ msgEmojiMore: 'More reactions',
+ msgEmojiNone: 'No match',
msgEdited: '(edited)',
msgReact: 'React',
msgZap: 'Zap',
diff --git a/src/style.css b/src/style.css
index 13704f6..a5af604 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1008,3 +1008,7 @@ body::after {
.chat-attach-ph { display: block; width: 240px; max-width: 100%; max-height: 320px; margin-top: 6px; border-radius: 10px; background: var(--line); }
.chat-attach { display: inline-block; margin-top: 6px; }
.chat-clip { flex: none; align-self: flex-end; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; }
+.msg-sheet-emojis button.more { flex: 0 0 40px; font-size: 20px; color: var(--muted); }
+.emoji-grid { display: flex; flex-wrap: wrap; gap: 2px; max-height: 260px; overflow-y: auto; }
+.emoji-grid button { width: 40px; height: 40px; font-size: 22px; line-height: 1; border: 1px solid transparent; border-radius: 8px; background: none; padding: 0; }
+.emoji-grid button.on { border-color: var(--accent); }