OASIS_MOBILE/src/views/rooms_view.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

463 lines
27 KiB
JavaScript

const { div, h2, p, section, button, form, a, span, br, hr, label, textarea, input, select, option, img, table, tr, td, details, summary, ul, li } = require("../server/node_modules/hyperaxe")
const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderContentActions, renderInviteQrCard, renderModuleStatsBy, moduleIsEmpty, liveClock, renderPhoneChip, contentDeleteAction, paged, renderVideoGrid, renderLiveFeed } = require("./main_views")
const { renderEncryptedChip, renderTransportChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view")
const { renderStyledText } = require("../backend/renderStyledText")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const userId = config.keys.id
const ROOM_MAX = 50
const MODES = ["all", "recent", "mine", "live", "open", "invite", "closed"]
const safeArr = (v) => (Array.isArray(v) ? v : [])
const roomHref = (room) => `/rooms/${encodeURIComponent(room.rootId)}`
const occOf = (params, room) => (params && params.occupancy && params.occupancy.get(room.rootId)) || null
const modeLabel = (f) => String(i18n[`roomFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f).toUpperCase()
const ROOM_REACH = ["OPEN", "INVITE-ONLY"]
const roomReachLabel = (s) => s === "INVITE-ONLY" ? i18n.roomStatusInviteOnly : i18n.roomStatusOpen
const clearnetEligible = (room) => !!room && !room.tribeId && !room.encrypted && room.type === "OPEN" && String(room.status || "").toUpperCase() === "OPEN" && !room.isClosed
const roomClearnetHref = (room) => clearnetItemHref("rooms", room.title, room.rootId)
const renderRoomStatusChip = (room) => {
const s = room.isClosed ? "CLOSED" : room.type
const variant = s === "CLOSED" ? "closed" : s === "INVITE-ONLY" ? "whole" : "mutuals"
const icon = s === "CLOSED" ? "✗" : s === "INVITE-ONLY" ? "🔑" : "✓"
const label = s === "CLOSED" ? i18n.roomStatusClosed : s === "INVITE-ONLY" ? i18n.roomStatusInviteOnly : i18n.roomStatusOpen
return renderStateChip(variant, icon, label)
}
const renderRoomNumberChip = (room, occ, inside) => {
if (!room.number) return null
if (room.line === "SWITCHBOARD" && !inside && occ && Number(occ.count) > 0) return renderPhoneChip(room.number, i18n.roomNumberHint)
return span({ title: room.line !== "SWITCHBOARD" ? i18n.roomLineDndError : inside ? i18n.roomYouAreIn : i18n.roomNobodyInside }, renderStateChip("whole", "✆", room.number))
}
const renderRoomLineChip = (room) => room.isClosed || String(room.author) === String(userId)
? null
: renderStateChip(room.line === "SWITCHBOARD" ? "mutuals" : "hidden", null, String(room.line === "SWITCHBOARD" ? i18n.roomLineSwitchboard : i18n.phoneDnd).toUpperCase())
const renderLiveChip = (occ) => {
if (!occ) return null
const count = Number(occ.count) || 0
const max = occ.max || ROOM_MAX
return count > 0 ? renderStateChip("mutuals", "●", `${i18n.roomLiveChip} ${count}/${max}`) : renderStateChip("neutral", "●", `${count}/${max}`)
}
const roomChips = (room, occ, inside = false) => [
renderRoomStatusChip(room),
renderEncryptedChip(i18n),
room.type === "OPEN" && !room.tribeId ? renderTransportChip(i18n) : null,
renderRoomNumberChip(room, occ, inside),
renderRoomLineChip(room),
renderLiveChip(occ),
renderLifespanChip(room.lifetime, i18n)
].filter(Boolean)
const renderModeButtons = (currentFilter, emptyMod = false, modesAvail = null) =>
div({ class: "tribe-mode-buttons" },
...(emptyMod ? [] : [
MODES.filter(f => f === "all" || f === currentFilter || (modesAvail && modesAvail[f] === true)).map(f =>
form({ method: "GET", action: "/rooms" },
input({ type: "hidden", name: "filter", value: f }),
button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" }, modeLabel(f))
)
)
]),
form({ method: "GET", action: "/rooms" },
input({ type: "hidden", name: "filter", value: "create" }),
button({ type: "submit", class: "create-button" }, i18n.roomCreate)
)
)
const renderCover = (room, cls) => room.image ? img({ loading: "lazy", src: `/blob/${encodeURIComponent(room.image)}`, class: cls, alt: "" }) : null
const roomIsFull = (occ) => !!occ && occ.count >= ((occ && occ.max) || ROOM_MAX)
const joinForm = (room, occ, cls = "tribe-action-btn") => {
const max = (occ && occ.max) || ROOM_MAX
const count = occ ? occ.count : null
const full = count !== null && count >= max
return form({ method: "POST", action: `/rooms/join/${encodeURIComponent(room.rootId)}`, class: "room-join-form" },
button({ type: "submit", class: cls, ...(full ? { disabled: true } : {}) },
String(full ? i18n.roomFull : i18n.roomJoin).toUpperCase())
)
}
const renderRoomItem = (room, params = {}) => {
const inside = params.live && params.live.ref === room.rootId
const occ = inside && Number.isFinite(Number(params.live.count)) ? { count: Number(params.live.count), max: params.live.max } : occOf(params, room)
const canJoin = !room.isClosed && !inside && (room.type === "OPEN" || room.tribeId || String(room.author) === String(userId) || safeArr(room.members).includes(userId))
return li({ class: "mailing-archive-item room-item" },
div({ class: "emergency-update-head mailing-archive-head" },
div({ class: "mailing-archive-meta" },
a({ href: roomHref(room), class: "user-link" }, room.title || "—"),
span({ class: "card-label activity-update-counts mailing-counts" }, `👥: ${safeArr(room.members).length}`),
...roomChips(room, occ, inside),
room.clearnet === true && clearnetEligible(room) ? renderReachChip(true, i18n, roomClearnetHref(room)) : null,
inside ? renderStateChip("whole", "ꘒ", String(i18n.roomYouAreIn).toUpperCase()) : null
),
renderContentActions(room.rootId, roomHref(room), { spread: (params.spreadMap && params.spreadMap.get(room.rootId)) || null, author: room.author, favKind: "rooms", isFavorite: room.isFavorite, reportTitle: room.title, deleteAction: String(room.author) === String(userId) ? contentDeleteAction("room", room.rootId) : undefined })
)
)
}
const renderRoomForm = (room, params = {}) => {
const tribeId = String(params.tribeId || "")
const isEdit = !!room
const draft = isEdit ? room : (params.draft || {})
const reachRaw = String(params.reach || draft.status || "").toUpperCase()
const reach = ROOM_REACH.includes(reachRaw) ? reachRaw : "OPEN"
const withReach = !isEdit && !tribeId
const showClearnet = isEdit ? clearnetEligible(room) : (withReach && reach === "OPEN")
return div({ class: "div-center audio-form" },
h2(isEdit ? i18n.roomUpdateSectionTitle : i18n.roomCreateSectionTitle),
withReach
? [
form({ method: "GET", action: "/rooms" },
input({ type: "hidden", name: "filter", value: "create" }),
label(i18n.roomTypeLabel), br(),
div({ class: "apply-row" },
select({ name: "status", class: "report-category-select" },
...ROOM_REACH.map(s => option({ value: s, ...(reach === s ? { selected: true } : {}) }, roomReachLabel(s)))
),
button({ type: "submit", class: "create-button" }, i18n.apply || "Apply")
)
),
hr({ class: "form-sep" }),
h2({ class: "report-category-fixed" }, roomReachLabel(reach))
]
: null,
form({ method: "POST", action: isEdit ? `/rooms/update/${encodeURIComponent(room.rootId)}` : "/rooms/create", enctype: "multipart/form-data" },
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
withReach ? input({ type: "hidden", name: "status", value: reach }) : null,
span(i18n.title || "Title"), br(),
input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.roomTitlePlaceholder, value: draft.title || "" }), br(),
span(i18n.roomDescriptionLabel), br(),
textarea({ name: "description", rows: 4, maxlength: "1000", placeholder: i18n.roomDescriptionPlaceholder }, draft.description || ""), br(),
span(i18n.uploadMedia), br(),
input({ type: "file", name: "image", accept: "image/*" }), br(), br(),
span(i18n.roomTagsLabel), br(),
input({ type: "text", name: "tags", placeholder: i18n.tagsPlaceholder, value: safeArr(draft.tags).join(", ") }), br(),
showClearnet ? renderClearnetSelector(draft.clearnet === true || String(draft.clearnet || "") === "1", i18n) : null,
button({ type: "submit", class: "create-button" }, isEdit ? i18n.roomUpdate : i18n.roomCreate)
)
)
}
exports.renderRoomInvitePage = (code) =>
template(i18n.roomInviteMode || i18n.tribeInviteCodeText,
section(div({ class: "invite-page" },
h2(i18n.tribeInviteCodeText, code),
form({ method: "GET", action: "/rooms" },
input({ type: "hidden", name: "filter", value: "all" }),
button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
)
))
)
exports.roomsView = async (rooms, filter, roomToEdit, params = {}) => {
const list = safeArr(rooms)
const q = String(params.q || "").trim()
const isForm = filter === "create" || filter === "edit"
const emptyMod = moduleIsEmpty(list, filter || "all", "all", q)
const shown = q
? list.filter(r => [r.title, r.description, r.number, ...safeArr(r.tags)].some(v => String(v || "").toLowerCase().includes(q.toLowerCase())))
: list
return template(i18n.roomsTitle,
section(
div({ class: "tags-header module-header-line" }, h2(i18n.roomsTitle), p(i18n.roomsDescription)),
renderModeButtons(filter, emptyMod, params.modesAvail || null),
!isForm && !emptyMod
? div({ class: "filters activity-filter-chips activity-toolbar-row" },
renderModuleStatsBy(shown, r => r.isClosed ? "CLOSED" : r.type, [{ value: "OPEN", label: i18n.roomStatusOpen }, { value: "INVITE-ONLY", label: i18n.roomStatusInviteOnly }, { value: "CLOSED", label: i18n.roomStatusClosed }]),
form({ method: "GET", action: "/rooms", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", placeholder: i18n.roomSearchPlaceholder, value: q, class: "filter-box__input" }),
div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.searchButton))
)
)
: null,
isForm
? renderRoomForm(filter === "edit" ? roomToEdit : null, params)
: (shown.length
? ul({ class: "mailing-archive" }, ...paged(shown).map(r => renderRoomItem(r, params)))
: div({ class: "tribe-grid" }, p(i18n.roomsNoItems)))
)
)
}
const eventLine = (ev) => {
const key = ev.t === "join" ? "roomEventJoined" : ev.t === "leave" ? "roomEventLeft" : ev.t === "recStart" ? "roomEventRecStart" : ev.t === "mute" ? "roomEventMuted" : ev.t === "unmute" ? "roomEventUnmuted" : ev.t === "hand" ? "roomEventHand" : "roomEventRecStop"
const [before, after] = String(i18n[key] || "{name}").split("{name}")
return [before, userLink(ev.id), after]
}
const renderRecChip = () => span({ class: "room-rec-chip" }, "● " + String(i18n.roomRecording).toUpperCase())
const fmtDuration = (ms) => { const s = Math.max(0, Math.floor((Number(ms) || 0) / 1000)); return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}` }
const refreshLink = (room) => a({ href: roomHref(room), class: "tribe-action-btn room-refresh-btn" }, String(i18n.liveRefresh).toUpperCase())
const renderLiveControls = (room, live) => div({ class: "room-live-controls" },
form({ method: "POST", action: "/rooms/notify", class: "phone-action-form" },
input({ type: "hidden", name: "on", value: live.notify ? "0" : "1" }),
button({ type: "submit", class: live.notify ? "tribe-action-btn" : "tribe-action-btn room-notify-off" }, "🔔 " + String(live.notify ? i18n.roomNotificationsOn : i18n.roomNotificationsOff).toUpperCase())
),
form({ method: "POST", action: "/rooms/hand", class: "phone-action-form" },
input({ type: "hidden", name: "hand", value: live.hand ? "0" : "1" }),
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
button({ type: "submit", class: live.hand ? "tribe-action-btn room-hand-btn-on" : "tribe-action-btn" }, "✋ " + String(live.hand ? i18n.roomLowerHand : i18n.roomRaiseHand).toUpperCase())
),
live.notify && safeArr(live.events).length
? form({ method: "POST", action: "/rooms/notify/clear", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomClearNotices).toUpperCase()))
: null,
form({ method: "POST", action: "/rooms/mute", class: "phone-action-form" },
input({ type: "hidden", name: "mute", value: live.muted ? "0" : "1" }),
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
button({ type: "submit", class: "tribe-action-btn" }, String(live.muted ? i18n.phoneUnmute : i18n.phoneMute).toUpperCase())
),
live.videoAvailable
? form({ method: "POST", action: "/rooms/camera", class: "phone-action-form" },
input({ type: "hidden", name: "cam", value: live.cam ? "0" : "1" }),
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
button({ type: "submit", class: live.cam ? "tribe-action-btn room-cam-btn-on" : "tribe-action-btn" }, String(live.cam ? i18n.phoneCameraOff : i18n.phoneCameraOn).toUpperCase())
)
: null,
live.recording
? form({ method: "POST", action: "/rooms/rec/stop", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn danger-btn" }, String(i18n.roomRecStop).toUpperCase()))
: form({ method: "POST", action: "/rooms/rec/start", class: "phone-action-form" }, button({ type: "submit", class: "tribe-action-btn" }, String(i18n.roomRec).toUpperCase())),
live.recording || safeArr(live.recordingBy).length ? renderRecChip() : null,
refreshLink(room),
form({ method: "POST", action: "/rooms/leave", class: "phone-action-form" },
input({ type: "hidden", name: "returnTo", value: roomHref(room) }),
button({ type: "submit", class: "tribe-action-btn danger-btn" }, String(i18n.roomLeave).toUpperCase())
)
)
const handOrder = (live) => {
const all = [{ you: true, hand: !!live.hand, since: Number(live.handSince) || 0 }, ...safeArr(live.peers).map(peer => ({ peer, hand: !!peer.hand, since: Number(peer.handSince) || 0 }))]
const raised = all.filter(e => e.hand).sort((a, b) => a.since - b.since)
raised.forEach((e, i) => { e.turn = i + 1 })
return [...raised, ...all.filter(e => !e.hand)]
}
const renderRoomEvents = (live) => live.notify && safeArr(live.events).length
? ul({ class: "room-events" }, ...safeArr(live.events).slice().reverse().map(ev => li({ class: "room-event" }, span({ class: "date-link" }, moment(ev.ts).format("HH:mm:ss")), span({ class: "room-event-text" }, ...eventLine(ev)))))
: null
const renderRecordings = (recordings) => safeArr(recordings).length
? div({ class: "phone-block room-recordings-block" },
h2(i18n.roomRecordings),
ul({ class: "room-recordings" }, ...recordings.map(r => li({ class: "room-recording" },
span({ class: "date-link" }, moment(r.at).format("YYYY/MM/DD HH:mm")),
span({ class: "phone-call-clock" }, fmtDuration(r.durationMs)),
a({ href: `/rooms/recordings/${encodeURIComponent(r.name)}`, class: "tribe-action-btn", download: r.name }, String(i18n.fileDownload).toUpperCase()),
form({ method: "POST", action: `/rooms/recordings/${encodeURIComponent(r.name)}/delete`, class: "phone-action-form" },
button({ type: "submit", class: "btn-singleview btn-delete", title: i18n.delete }, "✕"))
)))
)
: null
const renderLivePanel = (room, live, occ, canJoin = false) => {
const head = (left, controls = null) => div({ class: "room-live-head" },
div({ class: "room-live-head-main" }, left),
controls || refreshLink(room)
)
if (live) {
const silenceForm = (id, on) => form({ method: "POST", action: "/rooms/silence", class: "phone-action-form" },
input({ type: "hidden", name: "id", value: id }),
input({ type: "hidden", name: "on", value: on ? "0" : "1" }),
button({ type: "submit", class: on ? "tribe-action-btn danger-btn" : "tribe-action-btn" }, String(on ? i18n.phoneUnsilence : i18n.phoneSilence).toUpperCase())
)
const who = (id, muted, you, silenced = false, recording = false, turn = 0, cam = false) => div({ class: "phone-contact room-peer" + (turn ? " room-peer-raised" : "") },
turn ? span({ class: "room-peer-hand" }, `✋ ${turn}`) : null,
userLink(id),
you ? span({ class: "room-peer-you" }, i18n.roomYou) : null,
muted ? span({ class: "room-peer-muted" }, String(i18n.roomMuted).toUpperCase()) : null,
cam ? span({ class: "room-peer-cam" }, "CAM") : null,
recording ? renderRecChip() : null,
you ? null : silenceForm(id, silenced)
)
return div({ class: "phone-call-panel room-live-panel" },
head(div({ class: "card-chips-row" }, renderEncryptedChip(i18n), renderLiveChip({ count: live.count, max: live.max }), live.joinedAt ? span({ class: "phone-call-clock" }, liveClock(live.joinedAt)) : null), renderLiveControls(room, live)),
live.grid
? (safeArr(live.gridOrder).length
? div({ class: "video-grid video-grid-one" },
...renderLiveFeed("/phone/video/grid", "video-feed video-feed-grid"),
div({ class: "video-grid-names" }, ...safeArr(live.gridOrder).map(id => id === userId ? span({ class: "video-tile-name" }, i18n.roomYou) : userLink(id)))
)
: null)
: renderVideoGrid(safeArr(live.peers).map(p => ({ id: p.id, on: !!p.cam })), !!live.cam),
div({ class: "phone-contacts" }, ...handOrder(live).map(e => e.you
? who(userId, live.muted, true, false, !!live.recording, e.turn, !!live.cam)
: who(e.peer.id, e.peer.muted, false, !!e.peer.silenced, !!e.peer.recording, e.turn, !!e.peer.cam))),
renderRoomEvents(live)
)
}
const count = occ ? occ.count : 0
return div({ class: "phone-block room-live-panel" },
head(p({ class: "phone-empty" }, count > 0 ? String(i18n.roomSomeone).replace("{n}", String(count)) : i18n.roomNobody), canJoin ? div({ class: "room-live-controls" }, refreshLink(room), joinForm(room, occ)) : null)
)
}
exports.singleRoomView = async (room, params = {}) => {
const isAuthor = String(room.author) === String(userId)
const isMember = safeArr(room.members).includes(userId) || (!!room.tribeId && !!room.isTribeMember)
const restricted = !isMember && !isAuthor && room.type === "INVITE-ONLY"
const live = params.live && params.live.ref === room.rootId ? params.live : null
const occ = params.occ || null
const canJoin = !restricted && !room.isClosed && params.available !== false
const canClearnet = clearnetEligible(room)
const isClearnet = canClearnet && room.clearnet === true
const inviteActions = isAuthor && room.type === "INVITE-ONLY" && !room.tribeId && !room.isClosed
? [
form({ method: "POST", action: `/rooms/generate-invite/${encodeURIComponent(room.rootId)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
),
(() => {
const openInvite = safeArr(room.invites).find(inv => typeof inv === "object" && inv && inv.public === true && inv.code)
if (openInvite) return [
div({ class: "tribe-open-invite" },
span({ class: "card-label" }, i18n.tribeInviteCodeText),
span({ class: "tribe-open-invite-code" }, openInvite.code),
renderInviteQrCard({ qrDataUrl: `/qr-invite-code/rooms/${encodeURIComponent(openInvite.code)}` })
),
form({ method: "POST", action: `/rooms/open-invite/remove/${encodeURIComponent(room.rootId)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
)
]
return form({ method: "POST", action: `/rooms/open-invite/create/${encodeURIComponent(room.rootId)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
)
})()
].flat().filter(Boolean)
: []
const ownerActions = isAuthor
? [
room.isClosed ? null : form({ method: "POST", action: `/rooms/close/${encodeURIComponent(room.rootId)}` },
button({ type: "submit", class: "update-btn" }, i18n.roomClose)
),
form({ method: "GET", action: "/rooms" },
input({ type: "hidden", name: "filter", value: "edit" }),
input({ type: "hidden", name: "id", value: room.rootId }),
button({ type: "submit", class: "update-btn" }, i18n.roomUpdate)
)
].filter(Boolean)
: []
const side = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(room.rootId, roomHref(room), { spread: params.spreads || null, author: room.author, favKind: "rooms", isFavorite: room.isFavorite, returnTo: roomHref(room), reportTitle: room.title, deleteAction: isAuthor ? contentDeleteAction("room", room.rootId) : undefined })
),
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, restricted ? i18n.roomStatusInviteOnly : (room.title || "—"))),
div({ class: "card-chips-row" },
...roomChips(room, live ? { count: live.count, max: live.max } : occ, !!live),
canClearnet ? renderReachChip(isClearnet, i18n, isClearnet ? roomClearnetHref(room) : null) : null,
isAuthor && canClearnet ? renderClearnetSwitch("rooms", room.rootId, isClearnet) : null
),
restricted ? null : renderCover(room, "tribe-detail-image"),
restricted || !room.description ? null : p({ class: "tribe-side-description" }, ...renderStyledText(room.description)),
table({ class: "tribe-info-table jobs-info-table" },
tr(
td({ class: "tribe-info-label" }, i18n.createdAtLabel || "Created at"),
td({ class: "tribe-info-value", colspan: "3" }, moment(room.createdAt).format("YYYY/MM/DD HH:mm"))
),
restricted ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(room.author)))
),
live || restricted
? div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.roomParticipants}: ${safeArr(room.members).length}`)
)
: null,
inviteActions.length ? div({ class: "tribe-side-actions" }, ...inviteActions) : null,
isAuthor && !room.isClosed
? div({ class: "tribe-side-actions housing-status-row" },
span({ class: "card-label" }, `${i18n.statusLabel || "Status"}: `),
renderStateChip(room.line === "SWITCHBOARD" ? "mutuals" : "hidden", "✆", String(room.line === "SWITCHBOARD" ? i18n.roomLineSwitchboard : i18n.phoneDnd).toUpperCase()),
form({ method: "POST", action: `/rooms/line/${encodeURIComponent(room.rootId)}`, class: "inline-form" },
button({ class: "tribe-action-btn", type: "submit", name: "line", value: room.line === "SWITCHBOARD" ? "DND" : "SWITCHBOARD" },
String(room.line === "SWITCHBOARD" ? i18n.phoneDnd : i18n.roomLineSwitchboard).toUpperCase())
)
)
: null,
restricted
? div({ class: "tribe-side-actions" }, a({ class: "tribe-action-btn", href: "/invites?filter=others" }, i18n.tribeEnterInvite))
: null,
restricted || !safeArr(room.tags).length
? null
: div({ class: "card-tags" }, ...room.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}`, class: "tag-link" }, `#${t}`))),
ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null
)
const main = div({ class: "tribe-main" },
restricted
? p({ class: "access-denied-msg" }, i18n.roomAccessDenied)
: [
params.available === false ? div({ class: "pm-form-error-msg" }, p("✗ " + i18n.phoneUnavailable)) : null,
live
? renderLivePanel(room, live, occ)
: [
renderLivePanel(room, null, occ, canJoin),
details({ class: "pad-members-details", ...(safeArr(room.members).length <= 8 ? { open: true } : {}) },
summary({ class: "tribe-members-count" }, `${i18n.roomParticipants}: ${safeArr(room.members).length}`),
div({ class: "pad-members-chips" }, ...safeArr(room.members).map(m => span({ class: "pad-member-chip" }, userLink(m))))
)
],
renderRecordings(params.recordings)
]
)
return template(room.title || i18n.roomsTitle,
section(div({ class: "tags-header module-header-line" }, h2(i18n.roomsTitle), p(i18n.roomsDescription)), renderModeButtons("all", false, params.modesAvail || null)),
section(div({ class: "tribe-details" }, side, main))
)
}
exports.renderTribeRoomsSection = (tribe, rooms, occupancy, live, toolbar = null) => {
const items = safeArr(rooms)
const createBtn = form({ method: "GET", action: "/rooms" },
input({ type: "hidden", name: "filter", value: "create" }),
input({ type: "hidden", name: "tribeId", value: tribe.id }),
button({ type: "submit", class: "create-button" }, i18n.tribeRoomCreate))
const head = toolbar || div({ class: "tribe-content-header" }, h2(i18n.tribeSectionRooms), createBtn)
if (!items.length) return div({ class: "tribe-content-list" }, head, div({ class: "no-content-box" }, p(i18n.tribeRoomsEmpty)))
return div({ class: "tribe-content-list" },
head,
ul({ class: "mailing-archive" }, ...paged(items).map(r => renderRoomItem(r, { occupancy, live })))
)
}
exports.renderRoomItem = renderRoomItem
exports.roomStatsBy = (rooms) => renderModuleStatsBy(safeArr(rooms), r => r.isClosed ? "CLOSED" : r.type, [{ value: "OPEN", label: i18n.roomStatusOpen }, { value: "INVITE-ONLY", label: i18n.roomStatusInviteOnly }, { value: "CLOSED", label: i18n.roomStatusClosed }])
exports.clearnetRoomView = async (room) => {
const { escapeHtml: esc, renderKindTag, renderClearnetPage } = require("./clearnet_view")
const name = room.title || i18n.cnKindRoom
const extraCss = `
.cn-room-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700;word-break:break-word}
.cn-room-meta{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.cn-room-number{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--fg);border-radius:10px;padding:4px 12px;font-size:16px;font-weight:600;color:var(--fg);background:var(--bg-sub)}
.cn-room-number .pm-exposition-icon{font-size:18px;line-height:1}
.cn-room-number .pm-exposition-text{line-height:1;letter-spacing:1px;font-family:monospace}
`
const body = `
<h1 class="cn-room-title">${esc(name)}</h1>
<div class="cn-room-meta">
${renderKindTag("room")}
${room.number ? `<span class="pm-exposition-chip pm-exposition-whole cn-room-number"><span class="pm-exposition-icon">✆</span><span class="pm-exposition-text">${esc(room.number)}</span></span>` : ""}
</div>
`
return renderClearnetPage({
title: `${name} | Oasis`,
ogTitle: name,
ogDescription: "",
extraCss,
body,
hubFeedId: room.author || null
})
}
exports.roomModesFromCensus = (census, me, occupancy) => {
const list = safeArr(census)
return {
mine: list.some(x => String(x.author) === String(me)),
recent: list.length > 0,
live: list.some(x => { const o = occupancy && occupancy.get(x.rootId); return !!(o && o.count > 0) }),
open: list.some(x => x.type === "OPEN" && !x.isClosed),
invite: list.some(x => x.type === "INVITE-ONLY" && !x.isClosed),
closed: list.some(x => x.isClosed)
}
}