diff --git a/src/backend/backend.js b/src/backend/backend.js index d839e29..94d658b 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -3654,11 +3654,27 @@ router }) .get('/karvan/:id', async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; } - const room = karvanModel.getRoom(ctx.params.id); + let room = karvanModel.getRoom(ctx.params.id); + if (!room) room = karvanModel.adoptRoom({ id: ctx.params.id }); // unirse por enlace/invitación (espejo local) if (!room) { ctx.redirect('/karvan'); return; } let selfId = ''; try { selfId = getViewerId(); } catch (e) {} - const msgs = karvanModel.listMessages(ctx.params.id, 0) || []; - ctx.body = karvanRoomView(room, msgs, { selfId }); + const msgs = karvanModel.listMessages(room.id, 0) || []; + ctx.body = karvanRoomView(room, msgs, { selfId, invite: ctx.query.invite || '' }); + }) + .post('/karvan/:id/invite', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } + const id = ctx.params.id; + const room = karvanModel.getRoom(id); + if (!room) { ctx.redirect('/karvan'); return; } + const to = String((ctx.request.body || {}).to || '').trim(); + if (!/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(to)) { ctx.redirect('/karvan/' + id + '?invite=bad'); return; } + try { + // reutiliza el patrón de invitación por mensaje privado de Oasis (como el módulo industry): + // le llega al INBOX del contacto con un enlace a la sala. Sin código SSB nuevo. + await pmModel.sendMessage([to], 'KARVAN_INVITE', + 'You have been invited to an ephemeral Karvan room' + (room.title ? ' ("' + room.title + '")' : '') + ' -> /karvan/' + id); + ctx.redirect('/karvan/' + id + '?invite=ok'); + } catch (e) { ctx.redirect('/karvan/' + id + '?invite=fail'); } }) .post('/karvan/:id/msg', koaBody(), async (ctx) => { if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } diff --git a/src/client/assets/themes/OasisMobile.css b/src/client/assets/themes/OasisMobile.css index 21be6b7..e015b8c 100644 --- a/src/client/assets/themes/OasisMobile.css +++ b/src/client/assets/themes/OasisMobile.css @@ -1184,6 +1184,14 @@ body { padding-top: 84px; } .karvan-compose { display: flex; gap: 8px; margin-top: 10px; } .karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px; background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; } +/* Fase 2: invitar a un contacto por feed id */ +.karvan-invite { display: flex; gap: 8px; margin-top: 8px; } +.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px; + background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; } +.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; } +.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; } +.karvan-invite-msg.ok { color: #35d07f; } +.karvan-invite-msg.err { color: #ff8a8a; } /* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */ .karvan-call, .karvan-videos, .karvan-call-bar, .kc-status { diff --git a/src/models/karvan_model.js b/src/models/karvan_model.js index baee641..2aa4c74 100644 --- a/src/models/karvan_model.js +++ b/src/models/karvan_model.js @@ -72,6 +72,32 @@ module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_ return publicRoom(room); }; + // Fase 2 (invitar): unirse a una sala por su id creando un ESPEJO local (desde un enlace/invitación). + // El id es la "capacidad": quien lo tiene puede unirse. Aún NO conecta con la sala del otro nodo + // (eso es la señalización cross-device, pendiente); crea la sala local para poder abrir el enlace. + const adoptRoom = ({ id, title = "", ttlMinutes } = {}) => { + if (!id || typeof id !== "string" || !/^[0-9a-f]{6,64}$/.test(id)) return null; + const existing = rooms.get(id); + if (existing) return publicRoom(existing); + if (rooms.size >= MAX_ROOMS) { + const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0]; + if (oldest) destroy(oldest.id); + } + const createdAt = Date.now(); + let absMs = absTtlMs; + const t = parseInt(ttlMinutes, 10); + if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS); + const room = { + id, title: String(title || "").slice(0, 80), createdAt, expiresAt: createdAt + absMs, + messages: [], signals: [], members: new Set(), seq: 0, sigSeq: 0, idleTimer: null, absTimer: null, + }; + rooms.set(id, room); + room.absTimer = setTimeout(() => destroy(id), absMs); + if (room.absTimer.unref) room.absTimer.unref(); + resetIdle(room); + return publicRoom(room); + }; + const listRooms = () => [...rooms.values()].map(publicRoom).sort((a, b) => b.createdAt - a.createdAt); @@ -138,6 +164,7 @@ module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_ return { createRoom, + adoptRoom, listRooms, getRoom, postMessage, diff --git a/src/views/karvan_view.js b/src/views/karvan_view.js index c63e212..73afd90 100644 --- a/src/views/karvan_view.js +++ b/src/views/karvan_view.js @@ -101,7 +101,19 @@ exports.karvanRoomView = (room, messages, params = {}) => input({ type: "hidden", name: "from", value: params.selfId || "" }), input({ type: "text", name: "text", id: "karvan-text", placeholder: i18n.karvanSay || "Temporary message…", maxlength: "2000", autocomplete: "off" }), button({ type: "submit" }, i18n.karvanSend || "Send") - ) + ), + // Fase 2: invitar a un contacto por su feed id → le llega al inbox un enlace a esta sala + form({ class: "karvan-invite", method: "POST", action: "/karvan/" + room.id + "/invite" }, + input({ type: "text", name: "to", placeholder: i18n.karvanInvitePlaceholder || "Invite a contact by @feed-id…", autocomplete: "off", spellcheck: "false" }), + button({ type: "submit" }, i18n.karvanInvite || "Invite") + ), + params.invite === "ok" + ? p({ class: "karvan-invite-msg ok" }, i18n.karvanInviteSent || "✓ Invitation sent to their inbox.") + : params.invite === "bad" + ? p({ class: "karvan-invite-msg err" }, i18n.karvanInviteBad || "✗ That is not a valid @feed-id.") + : params.invite === "fail" + ? p({ class: "karvan-invite-msg err" }, i18n.karvanInviteFail || "✗ Could not send the invitation.") + : "" ), script({ src: "/js/karvan.js", defer: "defer" }) ); diff --git a/test/karvan_model.test.js b/test/karvan_model.test.js index 21f818c..86f033c 100644 --- a/test/karvan_model.test.js +++ b/test/karvan_model.test.js @@ -137,6 +137,20 @@ async function test(name, fn) { assert.ok(m.getRoom(ids[100])); // la última sigue }); + await test("adoptRoom: espejo local con id dado, idempotente, rechaza id inválido", () => { + const m = makeModel(); + const id = "abc123def456abc789"; + const r = m.adoptRoom({ id, title: "espejo" }); + assert.strictEqual(r.id, id); + assert.strictEqual(r.title, "espejo"); + assert.ok(m.getRoom(id)); + const again = m.adoptRoom({ id, title: "otro" }); // idempotente + assert.strictEqual(again.id, id); + assert.strictEqual(m.listRooms().filter((x) => x.id === id).length, 1); + assert.strictEqual(m.adoptRoom({ id: "NO-hex!" }), null); // id inválido + assert.ok(m.postMessage(id, { from: "a", text: "hola" }), "se puede postear en la adoptada"); + }); + console.log("\n" + passed + " passed, " + failed + " failed"); process.exit(failed ? 1 : 0); })();