[0.9.1] FORK_IA_UX v2: fix top-bar (visibility sheet) + Karvan module (ephemeral messages + WebRTC)

Navigation fixes:
- The bottom "Explore" sheet no longer blocks the top bar: closed state is
  visibility:hidden (out of hit-testing), so the top identity buttons and page
  content always receive taps (fixes "top buttons don't work").
- Top bar is now identity only (logo, avatar). The Personal/Community filter
  moved into the Explore sheet, next to the hive, as a segmented control.

New module "Karvan" (self-contained; inspired by karvan-protocol ephemeral rooms):
- Ephemeral/temporary chat rooms held only in RAM, self-destructing on idle
  (30 min) / absolute (2 h) TTL — nothing is written to disk.
- Server relay chat (RAM + polling) as the reliable path, plus a WebRTC
  data-channel layer (perfect negotiation, HTTP signaling mailbox) for instant
  P2P delivery; degrades to the relay if WebRTC is unavailable. Data-channel
  only (no camera/mic) so no wrapper permission changes are needed.
- Files: models/karvan_model.js, views/karvan_view.js, client/public/js/karvan.js;
  routes /karvan* + karvanMod config + nav entry (network) + karvanTitle i18n (11 langs).

Additive and gated by OASIS_MOBILE / karvanMod; Linux and the UX_OASIS branch untouched.
This commit is contained in:
s1to 2026-08-06 16:38:36 +02:00
parent 745c340917
commit e938c8bb42
19 changed files with 592 additions and 8 deletions

91
src/views/karvan_view.js Normal file
View file

@ -0,0 +1,91 @@
"use strict";
// Vista del módulo KARVAN (mensajes temporales + WebRTC). El chat va por relay servidor (RAM,
// autodestrucción) y, si conectan pares, por data-channel WebRTC (instantáneo). El <script> solo
// se carga en la página de sala; el WebRTC usa data-channel (sin cámara/mic => sin permisos extra).
const { div, section, h2, h3, p, a, form, input, button, span, ul, li, script, label } =
require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const fmtExpires = (expiresAt) => {
const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
return `${mins}m`;
};
const shortId = (id) => {
id = String(id || "?");
return id.startsWith("@") ? id.slice(1, 7) : id.slice(0, 6);
};
exports.karvanShortId = shortId;
const renderMsg = (m) =>
li({ class: "karvan-msg", "data-seq": String(m.seq), "data-mid": m.mid || "" },
span({ class: "km-from" }, shortId(m.from)),
span({ class: "km-text" }, m.text)
);
const ttlOpt = (val, lbl, checked) =>
label({ class: "karvan-ttl-opt" },
input(Object.assign({ type: "radio", name: "ttl", value: val }, checked ? { checked: "checked" } : {})),
span(lbl)
);
exports.karvanView = (rooms, params = {}) =>
template(
i18n.karvanTitle || "Karvan",
section({ class: "karvan-intro" },
h2("Karvan · " + (i18n.karvanEphemeral || "Ephemeral rooms")),
p({ class: "karvan-sub" },
i18n.karvanBlurb ||
"Temporary rooms that self-destruct. Messages live only in memory — never saved — and vanish when the room expires. Real-time over WebRTC when a peer connects.")
),
section({ class: "karvan-create" },
form({ method: "POST", action: "/karvan/create" },
input({ type: "text", name: "title", placeholder: i18n.karvanRoomName || "Room name (optional)", maxlength: "80", autocomplete: "off" }),
div({ class: "karvan-ttl" },
span({ class: "karvan-ttl-lbl" }, i18n.karvanTtl || "Self-destruct in:"),
ttlOpt("30", "30m", true), ttlOpt("120", "2h", false), ttlOpt("480", "8h", false)
),
button({ type: "submit" }, i18n.karvanCreate || "Create ephemeral room")
)
),
section({ class: "karvan-list" },
h3(i18n.karvanActive || "Active rooms"),
rooms && rooms.length
? ul({ class: "karvan-rooms" },
...rooms.map((r) =>
li(
a({ href: "/karvan/" + r.id, class: "karvan-room-item" },
span({ class: "kr-title" }, r.title || "room " + r.id.slice(0, 6)),
span({ class: "kr-meta" }, `${r.count} · ⏳ ${fmtExpires(r.expiresAt)}`)
)
)
)
)
: p({ class: "karvan-empty" }, i18n.karvanNone || "No active rooms. Create one — it disappears on its own.")
)
);
exports.karvanRoomView = (room, messages, params = {}) =>
template(
"Karvan · " + (room.title || room.id.slice(0, 6)),
section({ class: "karvan-room", "data-room": room.id, "data-self": params.selfId || "" },
div({ class: "karvan-room-head" },
a({ href: "/karvan", class: "karvan-back" }, "‹ " + (i18n.karvanRooms || "Rooms")),
span({ class: "karvan-room-title" }, room.title || "room " + room.id.slice(0, 6)),
span({ class: "karvan-expire", id: "karvan-expire" }, "⏳ " + fmtExpires(room.expiresAt))
),
div({ class: "karvan-live", id: "karvan-live" },
span({ class: "kl-dot" }),
span({ class: "kl-txt", id: "karvan-live-txt" }, i18n.karvanRelay || "server relay")
),
ul({ class: "karvan-msgs", id: "karvan-msgs" }, ...(messages || []).map(renderMsg)),
form({ class: "karvan-compose", method: "POST", action: "/karvan/" + room.id + "/msg", id: "karvan-form" },
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")
)
),
script({ src: "/js/karvan.js", defer: "defer" })
);

View file

@ -1105,14 +1105,12 @@ const renderTasksLink = () => {
// === Oasis UX (portado de 0.8.7_UX): topbar + bottombar + hive ===
// topbar fija arriba: [logo] [Personal] [Community] [avatar] en una línea (sin título OASIS)
const renderMobileTopbar = () => {
const hiveFilter = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || "";
// FORK_IA_UX v2: topbar SOLO identidad (logo→home, avatar→perfil). El filtro Personal/Community
// se movió a la hoja "Explore" (junto al hive). Así el top no tiene botones "huérfanos" que confundan.
return div(
{ class: "oasis-mobile-topbar" },
a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })),
div({ class: "omt-menus" },
a({ class: hiveFilter === "personal" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "personal" ? "/activity" : "/activity?hive=personal" }, span(i18n.menuPersonal || "Personal")),
a({ class: hiveFilter === "community" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "community" ? "/activity" : "/activity?hive=community" }, span(i18n.menuCommunity || "Community"))
),
div({ class: "omt-spacer" }),
a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" }))
);
};
@ -1124,6 +1122,7 @@ const PINNABLE_MODULES = [
{ key: 'activity', href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null },
{ key: 'tribes', href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' },
{ key: 'chats', href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
{ key: 'karvan', href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' },
{ key: 'market', href: '/market', glyph: 'ꕻ', labelKey: 'marketTitle', mod: 'marketMod' },
{ key: 'events', href: '/events', glyph: 'ꕆ', labelKey: 'eventsLabel', mod: 'eventsMod' },
{ key: 'calendars', href: '/calendars', glyph: 'ꖯ', labelKey: 'calendarsTitle', mod: 'calendarsMod' },
@ -1225,7 +1224,8 @@ const renderHiveNav = () => {
{ href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null }, { href: '/tags', glyph: 'ꖶ', labelKey: 'tagsLabel', mod: 'tagsMod' },
{ href: '/trending', glyph: 'ꗝ', labelKey: 'trendingLabel', mod: 'trendingMod' }, { href: '/opinions', glyph: 'ꔍ', labelKey: 'opinionsTitle', mod: 'opinionsMod' },
{ href: '/pads', glyph: 'ꔗ', labelKey: 'padsTitle', mod: 'padsMod' }, { href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' },
{ href: '/maps', glyph: 'ꔌ', labelKey: 'mapsLabel', mod: 'mapsMod' }, { href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' } ] },
{ href: '/maps', glyph: 'ꔌ', labelKey: 'mapsLabel', mod: 'mapsMod' }, { href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
{ href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' } ] },
{ id: 'blogs', emoji: '✦', title: i18n.menuBlogs, mods: [
{ href: '/mentions', glyph: '✺', labelKey: 'mentions', mod: null }, { href: '/public/latest', glyph: '☄', labelKey: 'latest', mod: 'latestMod' },
{ href: '/public/latest/threads', glyph: '♺', labelKey: 'threads', mod: 'threadsMod' }, { href: '/public/latest/topics', glyph: 'ϟ', labelKey: 'topics', mod: 'topicsMod' },
@ -1274,6 +1274,16 @@ const renderHiveSheet = () => {
label({ class: "hive-sheet-backdrop", for: "fork-explore-toggle", "aria-label": "Close" }),
div({ class: "hive-sheet-panel" },
label({ class: "hive-sheet-grip", for: "fork-explore-toggle", "aria-label": "Close" }),
// filtro Personal/Community/Todo (movido desde el topbar; filtra el hive server-side via ?hive=)
(() => {
const hf = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || "";
const seg = (href, lbl, on) => a({ class: on ? "hs-seg active" : "hs-seg", href }, lbl);
return div({ class: "hive-sheet-seg" },
seg("/activity", i18n.menuAll || "All", hf === ""),
seg("/activity?hive=personal", i18n.menuPersonal || "Personal", hf === "personal"),
seg("/activity?hive=community", i18n.menuCommunity || "Community", hf === "community")
);
})(),
// accesos que no están en el hive (CATS): red y estado
div({ class: "hive-sheet-quick" },
q("/peers", "⧖", "Peers"),