[0.9.1] FORK_IA_UX: Fase 2 (invite) — invite a contact to a Karvan room by feed id over SSB
Reuses Oasis's own private-message invite pattern (like the industry module: pmModel.sendMessage([feed], 'KARVAN_INVITE', '... -> /karvan/<id>')), so the invite lands in the contact's inbox with a link — no new SSB code, no touching the SSB startup. Joining a room by link adopts a local mirror (karvanModel.adoptRoom, the id is the capability). Real-time cross-device signaling (media/text sync between the two mirrors) is the remaining Fase 2 piece (muxrpc ephemeral), deferred. Verified single-node: invite publishes to the inbox, validation, adopt, form; 13/13 model tests (added adoptRoom).
This commit is contained in:
parent
813bf16958
commit
af160dbd26
5 changed files with 81 additions and 4 deletions
|
|
@ -3654,11 +3654,27 @@ router
|
||||||
})
|
})
|
||||||
.get('/karvan/:id', async (ctx) => {
|
.get('/karvan/:id', async (ctx) => {
|
||||||
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
|
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; }
|
if (!room) { ctx.redirect('/karvan'); return; }
|
||||||
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
|
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
|
||||||
const msgs = karvanModel.listMessages(ctx.params.id, 0) || [];
|
const msgs = karvanModel.listMessages(room.id, 0) || [];
|
||||||
ctx.body = karvanRoomView(room, msgs, { selfId });
|
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) => {
|
.post('/karvan/:id/msg', koaBody(), async (ctx) => {
|
||||||
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
|
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
|
||||||
|
|
|
||||||
|
|
@ -1184,6 +1184,14 @@ body { padding-top: 84px; }
|
||||||
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
|
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
|
||||||
.karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px;
|
.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; }
|
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 videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
|
||||||
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
|
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,32 @@ module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_
|
||||||
return publicRoom(room);
|
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 = () =>
|
const listRooms = () =>
|
||||||
[...rooms.values()].map(publicRoom).sort((a, b) => b.createdAt - a.createdAt);
|
[...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 {
|
return {
|
||||||
createRoom,
|
createRoom,
|
||||||
|
adoptRoom,
|
||||||
listRooms,
|
listRooms,
|
||||||
getRoom,
|
getRoom,
|
||||||
postMessage,
|
postMessage,
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,19 @@ exports.karvanRoomView = (room, messages, params = {}) =>
|
||||||
input({ type: "hidden", name: "from", value: params.selfId || "" }),
|
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" }),
|
input({ type: "text", name: "text", id: "karvan-text", placeholder: i18n.karvanSay || "Temporary message…", maxlength: "2000", autocomplete: "off" }),
|
||||||
button({ type: "submit" }, i18n.karvanSend || "Send")
|
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" })
|
script({ src: "/js/karvan.js", defer: "defer" })
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,20 @@ async function test(name, fn) {
|
||||||
assert.ok(m.getRoom(ids[100])); // la última sigue
|
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");
|
console.log("\n" + passed + " passed, " + failed + " failed");
|
||||||
process.exit(failed ? 1 : 0);
|
process.exit(failed ? 1 : 0);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue