bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

commit 9b077485e5d0c729630a3c63dbff85601bf7da1c
parent e68e36d4e38cdff13f5357a14668df5f7866b977
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Sat, 21 Feb 2026 16:51:38 -0700

added Stream posts -- support for Video/Audio/Screenshare -- check incoming docs

Diffstat:
Mpublic/app.js | 646++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpublic/index.html | 44+++++++++++++++++++++++++++++++++++++++-----
Mpublic/styles.css | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver.js | 405+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 1119 insertions(+), 33 deletions(-)

diff --git a/public/app.js b/public/app.js @@ -101,7 +101,9 @@ const postCollectionEl = document.getElementById("postCollection"); const keywordsEl = document.getElementById("keywords"); const ttlMinutesEl = document.getElementById("ttlMinutes"); const isProtectedEl = document.getElementById("isProtected"); -const isWalkieEl = document.getElementById("isWalkie"); +const postModeEl = document.getElementById("postMode"); +const streamKindRowEl = document.getElementById("streamKindRow"); +const streamKindEl = document.getElementById("streamKind"); const postPasswordEl = document.getElementById("postPassword"); const filterKeywordsEl = document.getElementById("filterKeywords"); @@ -142,6 +144,13 @@ const chatTitle = document.getElementById("chatTitle"); const chatMeta = document.getElementById("chatMeta"); const chatContextSelectEl = document.getElementById("chatContextSelect"); const chatBackToListBtn = document.getElementById("chatBackToList"); +const streamStageEl = document.getElementById("streamStage"); +const streamStageTitleEl = document.getElementById("streamStageTitle"); +const streamStageStatusEl = document.getElementById("streamStageStatus"); +const streamStagePrimaryBtn = document.getElementById("streamStagePrimary"); +const streamStageVideoEl = document.getElementById("streamStageVideo"); +const streamStageAudioEl = document.getElementById("streamStageAudio"); +const streamStagePlaceholderEl = document.getElementById("streamStagePlaceholder"); const chatMessagesEl = document.getElementById("chatMessages"); const typingIndicator = document.getElementById("typingIndicator"); const chatForm = document.getElementById("chatForm"); @@ -179,7 +188,9 @@ const editModalPostMeta = document.getElementById("editModalPostMeta"); const editModalKeywordsInput = document.getElementById("editModalKeywords"); const editModalCollectionSelect = document.getElementById("editModalCollection"); const editModalProtectedToggle = document.getElementById("editModalProtected"); -const editModalWalkieToggle = document.getElementById("editModalWalkie"); +const editModalModeSelect = document.getElementById("editModalMode"); +const editModalStreamKindRow = document.getElementById("editModalStreamKindRow"); +const editModalStreamKindSelect = document.getElementById("editModalStreamKind"); const editModalPasswordRow = document.getElementById("editModalPasswordRow"); const editModalPasswordInput = document.getElementById("editModalPassword"); const editModalToolbar = document.getElementById("editModalToolbar"); @@ -301,6 +312,17 @@ let walkieMicStream = null; let walkieMixNode = null; let walkieDestNode = null; let walkieDispatchBuffer = null; +let streamEnabled = false; +let streamIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }]; +const streamLiveByPostId = new Map(); +let streamCurrentPostId = ""; +let streamCurrentRole = "idle"; // "idle" | "viewer" | "host" +let streamCurrentHostClientId = ""; +let streamRemoteHostClientId = ""; +let streamLocalMedia = null; +let streamRemoteMedia = null; +let streamRemoteKind = "webcam"; +const streamPeerByClientId = new Map(); const SESSION_TOKEN_KEY = "bzl_session_token"; const CLIENT_IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024; @@ -2436,7 +2458,14 @@ function renderChatPostPanelInstance(panelId, forceScroll) { const author = post.author ? `by @${post.author}` : ""; const exp = formatCountdown(post.expiresAt); const ro = post.readOnly ? " | read-only" : ""; - metaEl.textContent = `${author}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); + const mode = normalizePostMode(post.mode || post.chatMode || ""); + const modeMeta = + mode === "walkie" + ? " | walkie talkie" + : mode === "stream" + ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})` + : ""; + metaEl.textContent = `${author}${modeMeta}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); } } @@ -3775,7 +3804,9 @@ function renderChatContextSelect() { const unread = Number(unreadByPostId.get(postId) || 0); const unreadLabel = unread > 0 ? ` (${unread})` : ""; const when = shortTimeAgo(postChatActivityAt(postId, post)); - opt.textContent = `${postTitle(post)}${unreadLabel}${when ? ` • ${when}` : ""}${post.author ? ` - @${String(post.author || "")}` : ""}`; + const mode = normalizePostMode(post.mode || post.chatMode || ""); + const streamLabel = mode === "stream" ? " [stream]" : ""; + opt.textContent = `${postTitle(post)}${streamLabel}${unreadLabel}${when ? ` • ${when}` : ""}${post.author ? ` - @${String(post.author || "")}` : ""}`; postGroup.appendChild(opt); } chatContextSelectEl.appendChild(postGroup); @@ -4042,11 +4073,21 @@ function syncProtectedUi() { if (!on) postPasswordEl.value = ""; } +function syncComposerModeUi() { + const mode = normalizePostMode(postModeEl?.value || "text"); + if (postModeEl && postModeEl.value !== mode) postModeEl.value = mode; + const showStreamKind = mode === "stream"; + if (streamKindRowEl) streamKindRowEl.classList.toggle("hidden", !showStreamKind); + if (streamKindEl) streamKindEl.value = normalizeStreamKind(streamKindEl.value || "webcam"); +} + syncProtectedUi(); isProtectedEl?.addEventListener("change", () => { syncProtectedUi(); if (isProtectedEl?.checked) postPasswordEl?.focus(); }); +syncComposerModeUi(); +postModeEl?.addEventListener("change", () => syncComposerModeUi()); function setSidebarHidden(hidden) { if (!appRoot) return; @@ -4313,7 +4354,9 @@ function setEditModalOpen(open) { if (editModalKeywordsInput) editModalKeywordsInput.value = ""; if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = ""; if (editModalProtectedToggle) editModalProtectedToggle.checked = false; - if (editModalWalkieToggle) editModalWalkieToggle.checked = false; + if (editModalModeSelect) editModalModeSelect.value = "text"; + if (editModalStreamKindSelect) editModalStreamKindSelect.value = "webcam"; + if (editModalStreamKindRow) editModalStreamKindRow.classList.add("hidden"); if (editModalPasswordInput) editModalPasswordInput.value = ""; if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden"); } @@ -4346,6 +4389,13 @@ function fillCollectionSelect(selectEl, currentId) { selectEl.value = current; } +function syncEditModalModeUi() { + const mode = normalizePostMode(editModalModeSelect?.value || "text"); + if (editModalModeSelect && editModalModeSelect.value !== mode) editModalModeSelect.value = mode; + if (editModalStreamKindRow) editModalStreamKindRow.classList.toggle("hidden", mode !== "stream"); + if (editModalStreamKindSelect) editModalStreamKindSelect.value = normalizeStreamKind(editModalStreamKindSelect.value || "webcam"); +} + function openEditModalForPost(post) { if (!post || post.deleted || post.locked) return; if (!loggedInUser || post.author !== loggedInUser) return; @@ -4357,7 +4407,9 @@ function openEditModalForPost(post) { if (editModalKeywordsInput) editModalKeywordsInput.value = (post.keywords || []).join(", "); fillCollectionSelect(editModalCollectionSelect, String(post.collectionId || "general")); if (editModalProtectedToggle) editModalProtectedToggle.checked = Boolean(post.protected); - if (editModalWalkieToggle) editModalWalkieToggle.checked = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; + if (editModalModeSelect) editModalModeSelect.value = normalizePostMode(post.mode || post.chatMode || ""); + if (editModalStreamKindSelect) editModalStreamKindSelect.value = normalizeStreamKind(post.streamKind || "webcam"); + syncEditModalModeUi(); if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !Boolean(post.protected)); if (editModalPasswordInput) editModalPasswordInput.value = ""; if (editModalEditor) editModalEditor.innerHTML = String(post.contentHtml || "").trim() || escapeHtml(post.content || ""); @@ -4376,7 +4428,9 @@ function openEditModalForChatMessage(message, postId) { if (editModalKeywordsInput) editModalKeywordsInput.value = ""; if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = ""; if (editModalProtectedToggle) editModalProtectedToggle.checked = false; - if (editModalWalkieToggle) editModalWalkieToggle.checked = false; + if (editModalModeSelect) editModalModeSelect.value = "text"; + if (editModalStreamKindSelect) editModalStreamKindSelect.value = "webcam"; + syncEditModalModeUi(); if (editModalPasswordInput) editModalPasswordInput.value = ""; if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden"); if (editModalEditor) editModalEditor.innerHTML = String(message.html || "").trim() || escapeHtml(message.text || ""); @@ -4389,6 +4443,7 @@ editModalProtectedToggle?.addEventListener("change", () => { if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !on); if (!on && editModalPasswordInput) editModalPasswordInput.value = ""; }); +editModalModeSelect?.addEventListener("change", () => syncEditModalModeUi()); function collectEditorPayload(targetEditor) { const html = String(targetEditor?.innerHTML || "").trim(); @@ -5633,6 +5688,30 @@ function parseKeywords(str) { return Array.from(new Set(parts)).slice(0, 6); } +function normalizePostMode(mode) { + const m = String(mode || "").trim().toLowerCase(); + if (m === "walkie") return "walkie"; + if (m === "stream") return "stream"; + return "text"; +} + +function normalizeStreamKind(kind) { + const k = String(kind || "").trim().toLowerCase(); + if (k === "screen" || k === "audio") return k; + return "webcam"; +} + +function streamKindLabel(kind) { + const k = normalizeStreamKind(kind); + if (k === "screen") return "Screen share"; + if (k === "audio") return "Audio only"; + return "Webcam"; +} + +function isStreamPost(post) { + return normalizePostMode(post?.mode || post?.chatMode || "") === "stream"; +} + function formatCountdown(expiresAt) { if (!Number(expiresAt || 0) || Number(expiresAt) <= 0) return "permanent"; const ms = expiresAt - Date.now(); @@ -6268,6 +6347,13 @@ function renderFeed() { const canBoost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser !== p.author); const canManageOwnPost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser === p.author); + const streamMode = normalizePostMode(p.mode || p.chatMode || ""); + const isStream = streamMode === "stream"; + const streamLive = Boolean(streamLiveByPostId.get(p.id) ?? p.streamLive); + const streamKind = normalizeStreamKind(p.streamKind || "webcam"); + const streamLine = isStream + ? `<div class="small muted postStreamLine">Stream · ${escapeHtml(streamKindLabel(streamKind))}${streamLive ? " · live now" : ""}</div>` + : ""; const boostControls = canBoost ? `<div class="boostRow"> <select data-boostsel="${p.id}"> @@ -6339,7 +6425,7 @@ function renderFeed() { ${boostLine} ${boostControls} <div class="postActionsRow"> - <button type="button" data-chat="${p.id}">${p.locked ? "Unlock" : p.deleted ? "View" : "Chat"}</button> + <button type="button" data-chat="${p.id}">${p.locked ? "Unlock" : p.deleted ? "View" : isStream ? "Watch" : "Chat"}</button> ${kebabBtn} ${postMenu} </div> @@ -6347,6 +6433,7 @@ function renderFeed() { </div> ${deletedLine} ${editedLine} + ${streamLine} ${contentBlock} ${typingLine} ${lastChatLine} @@ -6403,9 +6490,12 @@ function renderMobileChatListHtml() { const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon"; const exp = formatCountdown(p.expiresAt); const lock = p.locked ? " · locked" : ""; + const mode = normalizePostMode(p.mode || p.chatMode || ""); + const streamLive = mode === "stream" && Boolean(streamLiveByPostId.get(p.id) ?? p.streamLive); + const streamTag = mode === "stream" ? ` · stream${streamLive ? " live" : ""}` : ""; return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}"> <span class="mobileChatListTop">${title}</span> - <span class="mobileChatListMeta">${author} · ${escapeHtml(exp)}${lock}</span> + <span class="mobileChatListMeta">${author} · ${escapeHtml(exp)}${lock}${streamTag}</span> </button>`; }; @@ -7619,7 +7709,15 @@ function renderChatPanel(forceScroll = false) { renderChatContextSelect(); const mobileChatScreen = isMobileChatScreenActive(); const mediaState = captureMediaState(chatMessagesEl); + const activePost = activeChatPostId ? posts.get(activeChatPostId) : null; + if ( + streamCurrentPostId && + (activeDmThreadId || !activePost || !isStreamPost(activePost) || String(activePost.id || "") !== String(streamCurrentPostId)) + ) { + leaveActiveStream(true); + } if (activeDmThreadId) { + renderStreamStage(null); const thread = dmThreadsById.get(activeDmThreadId) || null; if (!thread) { activeDmThreadId = null; @@ -7689,8 +7787,9 @@ function renderChatPanel(forceScroll = false) { } } - const post = activeChatPostId ? posts.get(activeChatPostId) : null; + const post = activePost; if (!post) { + renderStreamStage(null); if (isMapChatActive()) { const mapId = String(activeMapsRoomId || "").trim().toLowerCase(); const scope = normalizeMapChatScope(activeMapsChatScope); @@ -7783,7 +7882,10 @@ function renderChatPanel(forceScroll = false) { } updateChatModToggleVisibility(); - const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; + renderStreamStage(post); + const mode = normalizePostMode(post.mode || post.chatMode || ""); + const isWalkie = mode === "walkie"; + const isStream = mode === "stream"; if (chatPanelEl) chatPanelEl.classList.toggle("walkie", isWalkie); if (walkieBarEl) walkieBarEl.classList.toggle("hidden", !isWalkie); if (chatForm) chatForm.classList.toggle("hidden", isWalkie); @@ -7799,7 +7901,10 @@ function renderChatPanel(forceScroll = false) { const author = post.author ? `by @${post.author}` : ""; const exp = formatCountdown(post.expiresAt); const ro = post.readOnly ? " | read-only" : ""; - chatMeta.textContent = `${author}${isWalkie ? " | walkie talkie" : ""}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); + const streamMeta = isStream ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})` : ""; + chatMeta.textContent = `${author}${isWalkie ? " | walkie talkie" : ""}${streamMeta}${ro} | ${ + exp === "permanent" ? "permanent" : `expires in ${exp}` + } | ${tags}`.trim(); const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly); if (chatEditor) chatEditor.contentEditable = String(Boolean(canChatWrite && !isWalkie)); const chatSendBtn = chatForm?.querySelector?.("button[type='submit']") || null; @@ -8090,7 +8195,10 @@ function updateActiveChatMeta() { const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); const author = post.author ? `by @${post.author}` : ""; const exp = formatCountdown(post.expiresAt); - chatMeta.textContent = `${author} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); + const mode = normalizePostMode(post.mode || post.chatMode || ""); + const modeMeta = + mode === "walkie" ? " | walkie talkie" : mode === "stream" ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})` : ""; + chatMeta.textContent = `${author}${modeMeta} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); } function openDmThread(threadId, opts = null) { @@ -8170,7 +8278,7 @@ function openChat(postId, opts = null) { } // Rack mode: switch the nearest visible chat panel when possible; otherwise use main chat. - if (rackLayoutEnabled) { + if (rackLayoutEnabled && !isStreamPost(post)) { const nearestInstanceId = nearestVisibleChatInstancePanelId(sourceEl); if (nearestInstanceId) { touchRecentHiveChat(postId); @@ -8597,6 +8705,371 @@ function cycleChatContextBy(step) { return false; } +function streamIceConfig() { + if (!Array.isArray(streamIceServers) || !streamIceServers.length) return []; + return streamIceServers.map((row) => { + const urls = Array.isArray(row?.urls) + ? row.urls.map((x) => String(x || "").trim()).filter(Boolean) + : typeof row?.urls === "string" && row.urls.trim() + ? [row.urls.trim()] + : []; + const out = { urls }; + if (typeof row?.username === "string" && row.username.trim()) out.username = row.username.trim(); + if (typeof row?.credential === "string" && row.credential.trim()) out.credential = row.credential.trim(); + return out; + }); +} + +function stopStreamTracks(stream) { + const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : []; + for (const track of tracks) { + try { + track.onended = null; + track.stop(); + } catch { + // ignore + } + } +} + +function closeStreamPeer(clientId) { + const id = String(clientId || "").trim(); + if (!id) return; + const pc = streamPeerByClientId.get(id); + if (!pc) return; + streamPeerByClientId.delete(id); + try { + pc.onicecandidate = null; + pc.onconnectionstatechange = null; + pc.ontrack = null; + pc.close(); + } catch { + // ignore + } +} + +function closeAllStreamPeers() { + for (const id of Array.from(streamPeerByClientId.keys())) closeStreamPeer(id); +} + +function clearStreamMediaPreview() { + if (streamStageVideoEl) { + try { + streamStageVideoEl.pause(); + } catch { + // ignore + } + streamStageVideoEl.srcObject = null; + streamStageVideoEl.classList.add("hidden"); + streamStageVideoEl.muted = false; + } + if (streamStageAudioEl) { + try { + streamStageAudioEl.pause(); + } catch { + // ignore + } + streamStageAudioEl.srcObject = null; + streamStageAudioEl.classList.add("hidden"); + streamStageAudioEl.muted = false; + } + streamRemoteMedia = null; +} + +function attachStreamPreview(stream, kind, local = false) { + const streamObj = stream && typeof stream.getTracks === "function" ? stream : null; + if (!streamObj) { + clearStreamMediaPreview(); + return; + } + const k = normalizeStreamKind(kind); + const hasVideo = streamObj.getVideoTracks().length > 0; + if (k === "audio" || !hasVideo) { + if (streamStageVideoEl) { + streamStageVideoEl.srcObject = null; + streamStageVideoEl.classList.add("hidden"); + } + if (streamStageAudioEl) { + streamStageAudioEl.srcObject = streamObj; + streamStageAudioEl.classList.remove("hidden"); + streamStageAudioEl.muted = Boolean(local); + streamStageAudioEl.play?.().catch(() => {}); + } + return; + } + if (streamStageAudioEl) { + streamStageAudioEl.srcObject = null; + streamStageAudioEl.classList.add("hidden"); + } + if (streamStageVideoEl) { + streamStageVideoEl.srcObject = streamObj; + streamStageVideoEl.classList.remove("hidden"); + streamStageVideoEl.muted = Boolean(local); + streamStageVideoEl.play?.().catch(() => {}); + } +} + +function streamCanHostPost(post) { + if (!post || !loggedInUser) return false; + if (String(post.author || "") === String(loggedInUser || "")) return true; + return loggedInRole === "owner" || loggedInRole === "moderator"; +} + +function streamResetState(keepPostId = false) { + closeAllStreamPeers(); + stopStreamTracks(streamLocalMedia); + streamLocalMedia = null; + streamRemoteMedia = null; + streamRemoteHostClientId = ""; + streamCurrentHostClientId = ""; + streamCurrentRole = "idle"; + if (!keepPostId) streamCurrentPostId = ""; + clearStreamMediaPreview(); +} + +function leaveActiveStream(sendSignal = true) { + const postId = String(streamCurrentPostId || "").trim(); + const wasRole = streamCurrentRole; + if (sendSignal && ws?.readyState === WebSocket.OPEN && postId) { + if (wasRole === "host") ws.send(JSON.stringify({ type: "streamHostStop", postId })); + else if (wasRole === "viewer") ws.send(JSON.stringify({ type: "streamLeave", postId })); + } + streamResetState(false); +} + +function streamStageCurrentPost() { + if (activeDmThreadId) return null; + const post = activeChatPostId ? posts.get(activeChatPostId) : null; + if (!post || post.deleted || !isStreamPost(post)) return null; + return post; +} + +function createStreamPeer(targetClientId) { + const target = String(targetClientId || "").trim(); + if (!target) return null; + if (typeof RTCPeerConnection !== "function") { + toast("Stream", "WebRTC is not available in this browser."); + return null; + } + const existing = streamPeerByClientId.get(target); + if (existing) return existing; + const pc = new RTCPeerConnection({ iceServers: streamIceConfig() }); + streamPeerByClientId.set(target, pc); + pc.onicecandidate = (evt) => { + if (!evt.candidate || !ws || ws.readyState !== WebSocket.OPEN || !streamCurrentPostId) return; + ws.send( + JSON.stringify({ + type: "streamSignal", + postId: streamCurrentPostId, + targetClientId: target, + signal: { type: "candidate", candidate: evt.candidate }, + }) + ); + }; + pc.ontrack = (evt) => { + if (streamCurrentRole !== "viewer") return; + const remote = evt.streams && evt.streams[0] ? evt.streams[0] : null; + if (!remote) return; + streamRemoteMedia = remote; + attachStreamPreview(remote, streamRemoteKind, false); + if (streamStagePlaceholderEl) streamStagePlaceholderEl.classList.add("hidden"); + }; + pc.onconnectionstatechange = () => { + const state = String(pc.connectionState || ""); + if (state === "failed" || state === "closed" || state === "disconnected") closeStreamPeer(target); + }; + if (streamCurrentRole === "host" && streamLocalMedia) { + for (const track of streamLocalMedia.getTracks()) { + try { + pc.addTrack(track, streamLocalMedia); + } catch { + // ignore + } + } + } + return pc; +} + +async function handleStreamSignalMessage(msg) { + const postId = String(msg.postId || "").trim(); + const fromClientId = String(msg.fromClientId || "").trim(); + const signal = msg.signal && typeof msg.signal === "object" ? msg.signal : null; + if (!postId || !fromClientId || !signal) return; + if (!streamCurrentPostId || streamCurrentPostId !== postId) return; + const type = String(signal.type || "").trim().toLowerCase(); + if (!type) return; + const pc = createStreamPeer(fromClientId); + if (!pc) return; + try { + if (type === "offer") { + await pc.setRemoteDescription(new RTCSessionDescription({ type: "offer", sdp: String(signal.sdp || "") })); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + ws?.send( + JSON.stringify({ + type: "streamSignal", + postId, + targetClientId: fromClientId, + signal: { type: "answer", sdp: String(answer.sdp || "") }, + }) + ); + return; + } + if (type === "answer") { + await pc.setRemoteDescription(new RTCSessionDescription({ type: "answer", sdp: String(signal.sdp || "") })); + return; + } + if (type === "candidate" && signal.candidate) { + await pc.addIceCandidate(new RTCIceCandidate(signal.candidate)); + } + } catch (e) { + console.warn("stream signal failed:", e?.message || e); + } +} + +async function handleStreamViewerJoinMessage(msg) { + const postId = String(msg.postId || "").trim(); + const viewerClientId = String(msg.viewerClientId || "").trim(); + if (!postId || !viewerClientId) return; + if (streamCurrentRole !== "host" || streamCurrentPostId !== postId) return; + const pc = createStreamPeer(viewerClientId); + if (!pc) return; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + ws?.send( + JSON.stringify({ + type: "streamSignal", + postId, + targetClientId: viewerClientId, + signal: { type: "offer", sdp: String(offer.sdp || "") }, + }) + ); + } catch (e) { + console.warn("stream offer failed:", e?.message || e); + closeStreamPeer(viewerClientId); + } +} + +async function startStreamHost(post) { + if (!post || !isStreamPost(post)) return; + if (!streamEnabled) { + toast("Stream", "Streaming is disabled on this instance."); + return; + } + if (!loggedInUser) { + toast("Stream", "Sign in to start a stream."); + return; + } + if (!streamCanHostPost(post)) { + toast("Stream", "Only the hive owner or a moderator can host this stream."); + return; + } + const kind = normalizeStreamKind(post.streamKind || "webcam"); + try { + if (!navigator.mediaDevices) throw new Error("Media devices are unavailable in this browser."); + let media = null; + if (kind === "screen") { + media = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); + } else if (kind === "audio") { + media = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + } else { + media = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); + } + if (!media) throw new Error("No stream media available."); + + leaveActiveStream(false); + streamCurrentPostId = String(post.id || ""); + streamCurrentRole = "host"; + streamRemoteKind = kind; + streamCurrentHostClientId = String(clientId || ""); + streamLocalMedia = media; + for (const track of media.getTracks()) { + track.onended = () => { + if (streamCurrentRole === "host" && streamCurrentPostId === String(post.id || "")) leaveActiveStream(true); + }; + } + attachStreamPreview(media, kind, true); + if (streamStagePlaceholderEl) streamStagePlaceholderEl.classList.add("hidden"); + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "streamHostStart", postId: post.id, streamKind: kind })); + } + renderChatPanel(false); + } catch (e) { + toast("Stream", String(e?.message || "Unable to start stream.")); + } +} + +function joinStream(post) { + if (!post || !isStreamPost(post)) return; + if (!streamEnabled) { + toast("Stream", "Streaming is disabled on this instance."); + return; + } + if (typeof RTCPeerConnection !== "function") { + toast("Stream", "WebRTC is not available in this browser."); + return; + } + leaveActiveStream(false); + streamCurrentPostId = String(post.id || ""); + streamCurrentRole = "viewer"; + streamRemoteKind = normalizeStreamKind(post.streamKind || "webcam"); + streamCurrentHostClientId = ""; + streamRemoteHostClientId = ""; + if (streamStagePlaceholderEl) { + streamStagePlaceholderEl.classList.remove("hidden"); + streamStagePlaceholderEl.textContent = "Connecting to stream..."; + } + if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "streamJoin", postId: post.id })); + renderChatPanel(false); +} + +function renderStreamStage(post) { + const streamPost = post && isStreamPost(post) ? post : null; + if (!streamPost) { + if (streamStageEl) streamStageEl.classList.add("hidden"); + return; + } + if (streamStageEl) streamStageEl.classList.remove("hidden"); + const postId = String(streamPost.id || ""); + const live = Boolean(streamLiveByPostId.get(postId) ?? streamPost.streamLive); + const kind = normalizeStreamKind(streamPost.streamKind || "webcam"); + const canHost = streamCanHostPost(streamPost); + const isHosting = streamCurrentRole === "host" && streamCurrentPostId === postId; + const isViewing = streamCurrentRole === "viewer" && streamCurrentPostId === postId; + if (streamStageTitleEl) { + streamStageTitleEl.textContent = `${streamKindLabel(kind)} stream`; + } + if (streamStageStatusEl) { + if (isHosting) streamStageStatusEl.textContent = "You are live."; + else if (isViewing) streamStageStatusEl.textContent = streamRemoteMedia ? "Watching live stream." : "Connecting..."; + else if (live) streamStageStatusEl.textContent = "Live now. Join to watch."; + else if (canHost) streamStageStatusEl.textContent = "Offline. Start a stream for this hive."; + else streamStageStatusEl.textContent = "Stream is offline."; + } + if (streamStagePrimaryBtn) { + let label = "Join stream"; + let disabled = false; + if (isHosting) label = "Stop stream"; + else if (isViewing) label = "Leave stream"; + else if (!live && canHost) label = `Go live (${streamKindLabel(kind)})`; + else if (!live && !canHost) { + label = "Offline"; + disabled = true; + } + streamStagePrimaryBtn.textContent = label; + streamStagePrimaryBtn.disabled = disabled; + } + if (streamStagePlaceholderEl && !isHosting && !isViewing) { + streamStagePlaceholderEl.classList.remove("hidden"); + streamStagePlaceholderEl.textContent = live + ? "Join this stream to watch live with chat." + : canHost + ? "Tap Go live to start screen share, webcam, or audio stream." + : "Waiting for the stream owner to go live."; + } +} + function canWalkieTalkNow() { if (!loggedInUser || !ws || ws.readyState !== WebSocket.OPEN) return false; if (!activeChatPostId) return false; @@ -8948,7 +9421,8 @@ editModalSaveBtn?.addEventListener("click", () => { } const keywords = parseKeywordsInput(editModalKeywordsInput?.value || ""); const collectionId = String(editModalCollectionSelect?.value || post?.collectionId || "general"); - const mode = Boolean(editModalWalkieToggle?.checked) ? "walkie" : "text"; + const mode = normalizePostMode(editModalModeSelect?.value || post?.mode || "text"); + const streamKind = normalizeStreamKind(editModalStreamKindSelect?.value || post?.streamKind || "webcam"); ws.send( JSON.stringify({ type: "editPost", @@ -8960,7 +9434,8 @@ editModalSaveBtn?.addEventListener("click", () => { collectionId, protected: wantsProtected, password: password.trim(), - mode + mode, + streamKind }) ); setEditModalOpen(false); @@ -9132,15 +9607,30 @@ newPostForm.addEventListener("submit", (e) => { toast("Protected post", "Password must be at least 4 characters."); return; } - const mode = Boolean(isWalkieEl?.checked) ? "walkie" : "text"; + const mode = normalizePostMode(postModeEl?.value || "text"); + const streamKind = normalizeStreamKind(streamKindEl?.value || "webcam"); ws.send( - JSON.stringify({ type: "newPost", title, collectionId, contentHtml: html, content: text, keywords, ttl, protected: isProtected, password, mode }) + JSON.stringify({ + type: "newPost", + title, + collectionId, + contentHtml: html, + content: text, + keywords, + ttl, + protected: isProtected, + password, + mode, + streamKind, + }) ); if (postTitleInput) postTitleInput.value = ""; editor.innerHTML = ""; if (postPasswordEl) postPasswordEl.value = ""; if (isProtectedEl) isProtectedEl.checked = false; - if (isWalkieEl) isWalkieEl.checked = false; + if (postModeEl) postModeEl.value = "text"; + if (streamKindEl) streamKindEl.value = "webcam"; + syncComposerModeUi(); if (isMobileSwipeMode()) setComposerOpen(false); }); @@ -10616,6 +11106,7 @@ function connectWs() { sock.addEventListener("close", () => { if (sock !== ws) return; + leaveActiveStream(false); setConn("closed"); clearWsKeepalive(); scheduleWsReconnect(); @@ -10639,6 +11130,7 @@ function onWsMessage(evt) { if (!msg || typeof msg !== "object") return; if (msg.type === "init") { + leaveActiveStream(false); clientId = msg.clientId || null; canRegisterFirstUser = Boolean(msg.auth?.canRegisterFirstUser); registrationEnabled = Boolean(msg.auth?.registrationEnabled); @@ -10661,6 +11153,8 @@ function onWsMessage(evt) { collections = normalizeCollections(msg.collections); customRoles = normalizeRoleDefs(msg.roles?.custom); setPlugins(msg.plugins); + streamEnabled = Boolean(msg.stream?.enabled); + streamIceServers = Array.isArray(msg.stream?.iceServers) && msg.stream.iceServers.length ? msg.stream.iceServers : streamIceServers; renderCollectionSelect(); peopleMembers = Array.isArray(msg.people?.members) ? msg.people.members : []; if (!peopleMembers.length && ws.readyState === WebSocket.OPEN) { @@ -10671,8 +11165,12 @@ function onWsMessage(evt) { if (msg.reactions?.allowedChat && Array.isArray(msg.reactions.allowedChat)) allowedChatReactions = msg.reactions.allowedChat; setUserPrefs({ starredPostIds: [], hiddenPostIds: [] }); unreadByPostId.clear(); + streamLiveByPostId.clear(); posts.clear(); - for (const p of msg.posts || []) posts.set(p.id, p); + for (const p of msg.posts || []) { + posts.set(p.id, p); + if (p && typeof p.id === "string") streamLiveByPostId.set(p.id, Boolean(p.streamLive)); + } setAuthUi(); renderFeed(); renderChatPanel(); @@ -10771,6 +11269,80 @@ function onWsMessage(evt) { return; } + if (msg.type === "streamState") { + const postId = String(msg.postId || "").trim(); + if (!postId) return; + const live = Boolean(msg.live); + streamLiveByPostId.set(postId, live); + const post = posts.get(postId); + if (post) { + post.streamLive = live; + post.streamKind = normalizeStreamKind(msg.kind || post.streamKind || "webcam"); + post.streamHost = String(msg.host || ""); + post.streamHostClientId = String(msg.hostClientId || ""); + post.streamViewerCount = Math.max(0, Number(msg.viewerCount || 0) || 0); + } + if (live && streamCurrentRole === "viewer" && streamCurrentPostId === postId && !streamCurrentHostClientId) { + streamCurrentHostClientId = String(msg.hostClientId || ""); + streamRemoteHostClientId = streamCurrentHostClientId; + } + if (!live && streamCurrentPostId === postId && streamCurrentRole !== "idle") { + leaveActiveStream(false); + } + renderFeed(); + if (activeChatPostId === postId || streamCurrentPostId === postId) renderChatPanel(false); + return; + } + + if (msg.type === "streamEnded") { + const postId = String(msg.postId || "").trim(); + if (!postId) return; + streamLiveByPostId.set(postId, false); + const post = posts.get(postId); + if (post) post.streamLive = false; + if (streamCurrentPostId === postId && streamCurrentRole !== "idle") { + leaveActiveStream(false); + if (activeChatPostId === postId) toast("Stream", "Live stream ended."); + } + renderFeed(); + if (activeChatPostId === postId) renderChatPanel(false); + return; + } + + if (msg.type === "streamJoinAck") { + const postId = String(msg.postId || "").trim(); + if (!postId || streamCurrentRole !== "viewer" || streamCurrentPostId !== postId) return; + if (!Boolean(msg.live)) { + leaveActiveStream(false); + toast("Stream", "This stream is offline."); + renderChatPanel(false); + return; + } + streamCurrentHostClientId = String(msg.hostClientId || ""); + streamRemoteHostClientId = streamCurrentHostClientId; + streamRemoteKind = normalizeStreamKind(msg.kind || "webcam"); + renderChatPanel(false); + return; + } + + if (msg.type === "streamViewerJoin") { + handleStreamViewerJoinMessage(msg); + return; + } + + if (msg.type === "streamViewerLeave") { + const postId = String(msg.postId || "").trim(); + const viewerClientId = String(msg.viewerClientId || "").trim(); + if (!postId || !viewerClientId) return; + if (streamCurrentRole === "host" && streamCurrentPostId === postId) closeStreamPeer(viewerClientId); + return; + } + + if (msg.type === "streamSignal") { + handleStreamSignalMessage(msg); + return; + } + if (msg.type === "collectionsUpdated") { const prevView = activeHiveView; collections = normalizeCollections(msg.collections); @@ -10803,8 +11375,12 @@ function onWsMessage(evt) { } if (msg.type === "postsSnapshot") { + streamLiveByPostId.clear(); posts.clear(); - for (const post of Array.isArray(msg.posts) ? msg.posts : []) posts.set(post.id, post); + for (const post of Array.isArray(msg.posts) ? msg.posts : []) { + posts.set(post.id, post); + if (post && typeof post.id === "string") streamLiveByPostId.set(post.id, Boolean(post.streamLive)); + } if (activeChatPostId && !posts.has(activeChatPostId)) { activeChatPostId = null; } @@ -10815,6 +11391,7 @@ function onWsMessage(evt) { if (msg.type === "boardReset") { posts.clear(); + streamLiveByPostId.clear(); chatByPost.clear(); unreadByPostId.clear(); typingUsersByPostId.clear(); @@ -10883,6 +11460,7 @@ function onWsMessage(evt) { if (msg.type === "newPost" && msg.post) { const isNewId = !posts.has(msg.post.id); posts.set(msg.post.id, msg.post); + streamLiveByPostId.set(msg.post.id, Boolean(msg.post.streamLive)); renderFeed(); if (isNewId) { newPostAnimIds.add(msg.post.id); @@ -10913,6 +11491,7 @@ function onWsMessage(evt) { if (msg.type === "postUpdated" && msg.post) { posts.set(msg.post.id, msg.post); + streamLiveByPostId.set(msg.post.id, Boolean(msg.post.streamLive)); renderFeed(); renderChatPanel(); return; @@ -10921,7 +11500,9 @@ function onWsMessage(evt) { if (msg.type === "deletePost") { if (userPrefs?.starredPostIds) userPrefs.starredPostIds = userPrefs.starredPostIds.filter((id) => id !== msg.id); if (userPrefs?.hiddenPostIds) userPrefs.hiddenPostIds = userPrefs.hiddenPostIds.filter((id) => id !== msg.id); + if (streamCurrentPostId && String(streamCurrentPostId) === String(msg.id || "")) leaveActiveStream(false); posts.delete(msg.id); + streamLiveByPostId.delete(msg.id); chatByPost.delete(msg.id); unreadByPostId.delete(msg.id); typingUsersByPostId.delete(msg.id); @@ -10972,6 +11553,7 @@ function onWsMessage(evt) { if (msg.type === "logoutOk") { setSessionToken(""); + leaveActiveStream(false); loggedInUser = null; loggedInRole = "member"; canModerate = false; @@ -10982,6 +11564,7 @@ function onWsMessage(evt) { activeDmThreadId = null; pendingOpenDmThreadId = ""; stopWalkieRecording(); + streamLiveByPostId.clear(); lanUrls = []; modReports = []; modUsers = []; @@ -11221,6 +11804,7 @@ function onWsMessage(evt) { const postId = msg.postId || ""; if (!postId || !msg.post) return; posts.set(postId, msg.post); + streamLiveByPostId.set(postId, Boolean(msg.post.streamLive)); if (Array.isArray(msg.messages)) chatByPost.set(postId, msg.messages); renderFeed(); renderChatPanel(); @@ -11876,6 +12460,26 @@ mobileMoreSheetEl?.addEventListener("click", (e) => { if (target.closest?.("[data-mobilemoreclose]")) setMobileMoreOpen(false); }); +streamStagePrimaryBtn?.addEventListener("click", () => { + const post = streamStageCurrentPost(); + if (!post) return; + const postId = String(post.id || ""); + if (!postId) return; + if (streamCurrentRole === "host" && streamCurrentPostId === postId) { + leaveActiveStream(true); + renderChatPanel(false); + return; + } + if (streamCurrentRole === "viewer" && streamCurrentPostId === postId) { + leaveActiveStream(true); + renderChatPanel(false); + return; + } + const live = Boolean(streamLiveByPostId.get(postId) ?? post.streamLive); + if (live) joinStream(post); + else startStreamHost(post); +}); + walkieRecordBtn?.addEventListener("pointerdown", (e) => { e.preventDefault(); startWalkieRecording(); diff --git a/public/index.html b/public/index.html @@ -347,8 +347,20 @@ <input id="isProtected" type="checkbox" /> </label> <label class="grow"> - <span>Walkie Talkie</span> - <input id="isWalkie" type="checkbox" /> + <span>Hive mode</span> + <select id="postMode"> + <option value="text" selected>Text</option> + <option value="walkie">Walkie Talkie</option> + <option value="stream">Stream</option> + </select> + </label> + <label id="streamKindRow" class="grow hidden"> + <span>Stream type</span> + <select id="streamKind"> + <option value="screen">Screen share</option> + <option value="webcam" selected>Webcam</option> + <option value="audio">Audio only</option> + </select> </label> <label class="grow"> <span>Post password</span> @@ -376,6 +388,16 @@ </div> </div> <div class="uiHint">Select a hive chat or DM first, then type your message and press Send. Shortcut in Chat: <b>-</b>/<b>=</b> cycles chat list entries.</div> + <section id="streamStage" class="streamStage hidden" aria-label="Live stream stage"> + <div class="streamStageHeader"> + <div id="streamStageTitle" class="streamStageTitle">Stream</div> + <button id="streamStagePrimary" class="ghost smallBtn" type="button">Join stream</button> + </div> + <div id="streamStageStatus" class="small muted">Stream is offline.</div> + <video id="streamStageVideo" class="streamStageVideo hidden" playsinline autoplay controls muted></video> + <audio id="streamStageAudio" class="streamStageAudio hidden" autoplay controls></audio> + <div id="streamStagePlaceholder" class="streamStagePlaceholder small muted">Open a stream hive to watch, or start a stream if you own the post.</div> + </section> <div id="chatMessages" class="chatMessages"></div> <div id="typingIndicator" class="typingIndicator small muted"></div> <div id="walkieBar" class="walkieBar hidden" aria-label="Walkie talkie controls"> @@ -526,9 +548,21 @@ <span>Password protected</span> <input id="editModalProtected" type="checkbox" /> </label> - <label class="checkRow"> - <span>Walkie-only</span> - <input id="editModalWalkie" type="checkbox" /> + <label> + <span>Mode</span> + <select id="editModalMode"> + <option value="text" selected>Text</option> + <option value="walkie">Walkie Talkie</option> + <option value="stream">Stream</option> + </select> + </label> + <label id="editModalStreamKindRow" class="hidden"> + <span>Stream type</span> + <select id="editModalStreamKind"> + <option value="screen">Screen share</option> + <option value="webcam" selected>Webcam</option> + <option value="audio">Audio only</option> + </select> </label> <label id="editModalPasswordRow" class="hidden"> <span>Password</span> diff --git a/public/styles.css b/public/styles.css @@ -1440,6 +1440,48 @@ body { display: none; } +.streamStage { + margin: 10px 12px 0; + padding: 10px; + border: 1px solid rgba(246, 240, 255, 0.14); + border-radius: 14px; + background: rgba(255, 255, 255, 0.02); + display: flex; + flex-direction: column; + gap: 8px; +} + +.streamStageHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.streamStageTitle { + font-weight: 800; +} + +.streamStageVideo, +.streamStageAudio { + width: 100%; + border-radius: 12px; + border: 1px solid rgba(246, 240, 255, 0.12); + background: rgba(0, 0, 0, 0.25); +} + +.streamStageVideo { + max-height: min(42vh, 320px); + object-fit: contain; +} + +.streamStagePlaceholder { + padding: 8px 10px; + border: 1px dashed rgba(246, 240, 255, 0.16); + border-radius: 12px; + background: rgba(0, 0, 0, 0.12); +} + .app.sidebarHidden .sidebar { display: none; } @@ -2247,6 +2289,11 @@ button:disabled { margin-top: 6px; } +.postStreamLine { + margin-top: 6px; + color: color-mix(in srgb, var(--accent) 52%, var(--text)); +} + .reactionsRow { display: flex; gap: 6px; @@ -3393,6 +3440,16 @@ button:disabled { padding: 8px 8px 6px; } +.app.mobileScreens .streamStage { + margin: 8px; + padding: 8px; + gap: 6px; +} + +.app.mobileScreens .streamStageVideo { + max-height: min(28vh, 220px); +} + .app.mobileScreens .chatMsg { padding: 6px 7px; margin-top: 6px; diff --git a/server.js b/server.js @@ -98,6 +98,8 @@ const AUDIO_UPLOAD_MAX_BYTES = Number(process.env.AUDIO_UPLOAD_MAX_BYTES || 150 const PLUGINS_DIR = process.env.PLUGINS_DIR || path.join(__dirname, "data", "plugins"); const PLUGINS_FILE = process.env.PLUGINS_FILE || path.join(__dirname, "data", "plugins.json"); const PLUGIN_ZIP_MAX_BYTES = Number(process.env.PLUGIN_ZIP_MAX_BYTES || 50 * 1024 * 1024); +const STREAM_ENABLED = String(process.env.STREAM_ENABLED || "1") !== "0"; +const STREAM_ICE_SERVERS_JSON = typeof process.env.STREAM_ICE_SERVERS_JSON === "string" ? process.env.STREAM_ICE_SERVERS_JSON : ""; const publicDir = path.join(__dirname, "public"); @@ -233,6 +235,8 @@ let pluginRuntimeById = new Map(); /** @type {Map<string, Map<string, NodeJS.Timeout>>} */ const typingByPostId = new Map(); +/** @type {Map<string, {postId: string, hostClientId: string, hostUsername: string, kind: string, viewers: Set<string>, startedAt: number}>} */ +const streamSessionsByPostId = new Map(); const ALLOWED_POST_REACTIONS = ["👍", "❤️", "😡", "😭", "🥺", "😂", "⭐"]; const ALLOWED_CHAT_REACTIONS = ["👍", "❤️", "😡", "😭", "🥺", "😂"]; @@ -251,6 +255,47 @@ const ROLE_MODERATOR = "moderator"; const ROLE_OWNER = "owner"; const ROLE_RANK = { [ROLE_MEMBER]: 1, [ROLE_MODERATOR]: 2, [ROLE_OWNER]: 3 }; const DEFAULT_COLLECTION_ID = "general"; +const POST_MODE_TEXT = "text"; +const POST_MODE_WALKIE = "walkie"; +const POST_MODE_STREAM = "stream"; +const STREAM_KIND_WEBCAM = "webcam"; +const STREAM_KIND_SCREEN = "screen"; +const STREAM_KIND_AUDIO = "audio"; +const STREAM_KIND_SET = new Set([STREAM_KIND_WEBCAM, STREAM_KIND_SCREEN, STREAM_KIND_AUDIO]); + +function parseStreamIceServers(raw) { + const fallback = [{ urls: ["stun:stun.l.google.com:19302"] }]; + const input = String(raw || "").trim(); + if (!input) return fallback; + try { + const parsed = JSON.parse(input); + if (!Array.isArray(parsed)) return fallback; + const out = []; + for (const item of parsed.slice(0, 6)) { + if (!item || typeof item !== "object") continue; + const urls = Array.isArray(item.urls) + ? item.urls + .map((x) => String(x || "").trim()) + .filter((x) => /^stuns?:|^turns?:/i.test(x)) + .slice(0, 8) + : typeof item.urls === "string" && /^stuns?:|^turns?:/i.test(item.urls.trim()) + ? [item.urls.trim()] + : []; + if (!urls.length) continue; + const row = { urls }; + const username = typeof item.username === "string" ? item.username.trim() : ""; + const credential = typeof item.credential === "string" ? item.credential.trim() : ""; + if (username) row.username = username.slice(0, 140); + if (credential) row.credential = credential.slice(0, 220); + out.push(row); + } + return out.length ? out : fallback; + } catch { + return fallback; + } +} + +const STREAM_ICE_SERVERS = parseStreamIceServers(STREAM_ICE_SERVERS_JSON); function now() { return Date.now(); @@ -1135,13 +1180,20 @@ function serializeChatHistoryForWs(entry) { } function serializePostForWs(ws, post) { + const mode = sanitizePostMode(post.mode); + const streamKind = sanitizePostStreamKind(mode, post.streamKind); + const streamSession = mode === POST_MODE_STREAM ? streamSessionsByPostId.get(post.id) : null; const base = { id: post.id, title: post.title || "", content: post.content || "", contentHtml: post.contentHtml || "", author: post.author, - mode: sanitizePostMode(post.mode), + mode, + streamKind, + streamLive: Boolean(streamSession), + streamHost: streamSession?.hostUsername || "", + streamViewerCount: streamSession?.viewers?.size || 0, readOnly: Boolean(post.readOnly), collectionId: normalizeCollectionId(post.collectionId || "") || DEFAULT_COLLECTION_ID, keywords: post.keywords, @@ -1176,6 +1228,9 @@ function serializePostForWs(ws, post) { title: showDeleted ? "Post was deleted" : "", content: showDeleted ? "This post was deleted." : "", contentHtml: "", + streamLive: false, + streamHost: "", + streamViewerCount: 0, keywords: [], reactions: {} }; @@ -2559,6 +2614,8 @@ function loadPostsFromDisk() { title: snapTitle || "(untitled)", content: snapContentText || (snapContentHtml ? "[media]" : ""), contentHtml: snapContentHtml, + mode: sanitizePostMode(sp.mode || sp.chatMode || ""), + streamKind: sanitizePostStreamKind(sp.mode || sp.chatMode || "", sp.streamKind), collectionId: getActiveCollectionById(sp.collectionId)?.id || DEFAULT_COLLECTION_ID, keywords: normalizeKeywords(sp.keywords), author: snapAuthor || null, @@ -2583,7 +2640,8 @@ function loadPostsFromDisk() { title: title || "(untitled)", content: contentText || (contentHtml ? "[media]" : ""), contentHtml, - mode: sanitizePostMode(p.mode), + mode: sanitizePostMode(p.mode || p.chatMode || ""), + streamKind: sanitizePostStreamKind(p.mode || p.chatMode || "", p.streamKind), readOnly: Boolean(p.readOnly), collectionId: getActiveCollectionById(p.collectionId)?.id || DEFAULT_COLLECTION_ID, keywords: normalizeKeywords(p.keywords), @@ -2701,7 +2759,17 @@ function sanitizePostTitle(title) { function sanitizePostMode(mode) { const m = String(mode || "").trim().toLowerCase(); - return m === "walkie" ? "walkie" : "text"; + if (m === POST_MODE_WALKIE) return POST_MODE_WALKIE; + if (m === POST_MODE_STREAM) return POST_MODE_STREAM; + return POST_MODE_TEXT; +} + +function sanitizePostStreamKind(mode, kind) { + const m = sanitizePostMode(mode); + if (m !== POST_MODE_STREAM) return ""; + const k = String(kind || "").trim().toLowerCase(); + if (STREAM_KIND_SET.has(k)) return k; + return STREAM_KIND_WEBCAM; } function broadcast(obj) { @@ -2711,6 +2779,100 @@ function broadcast(obj) { } } +function findSocketByClientId(clientId) { + const id = typeof clientId === "string" ? clientId : ""; + if (!id) return null; + for (const ws of sockets) { + if (ws?.clientId === id) return ws; + } + return null; +} + +function canSocketAccessPost(ws, post) { + if (!ws || !post) return false; + if (!canUserSeePostByCollection(ws.user?.username || "", post)) return false; + if (post.protected && !hasPostAccess(ws, post)) return false; + return true; +} + +function streamStatePayload(postId) { + const entry = posts.get(postId); + if (!entry?.post) return null; + const postMode = sanitizePostMode(entry.post.mode); + if (postMode !== POST_MODE_STREAM) { + return { type: "streamState", postId, live: false, kind: "", host: "", hostClientId: "", viewerCount: 0 }; + } + const session = streamSessionsByPostId.get(postId); + return { + type: "streamState", + postId, + live: Boolean(session), + kind: sanitizePostStreamKind(postMode, session?.kind || entry.post.streamKind || ""), + host: session?.hostUsername || "", + hostClientId: session?.hostClientId || "", + viewerCount: session?.viewers?.size || 0 + }; +} + +function sendStreamState(postId) { + const payload = streamStatePayload(postId); + if (!payload) return; + const entry = posts.get(postId); + if (!entry?.post) return; + sendToSockets((ws) => canSocketAccessPost(ws, entry.post), payload); +} + +function endStreamSession(postId, reason = "ended") { + const session = streamSessionsByPostId.get(postId); + if (!session) return false; + streamSessionsByPostId.delete(postId); + const notify = { + type: "streamEnded", + postId, + reason: String(reason || "ended") + .trim() + .slice(0, 80) + }; + const targets = new Set([session.hostClientId, ...(session.viewers || [])]); + for (const clientId of targets) { + const target = findSocketByClientId(clientId); + if (!target || target.readyState !== target.OPEN) continue; + target.send(JSON.stringify(notify)); + } + sendStreamState(postId); + return true; +} + +function detachViewerFromStream(postId, clientId, notifyHost = true) { + const session = streamSessionsByPostId.get(postId); + if (!session) return false; + if (!session.viewers.has(clientId)) return false; + session.viewers.delete(clientId); + if (notifyHost) { + const host = findSocketByClientId(session.hostClientId); + if (host && host.readyState === host.OPEN) { + host.send(JSON.stringify({ type: "streamViewerLeave", postId, viewerClientId: clientId })); + } + } + sendStreamState(postId); + return true; +} + +function detachSocketFromStreams(ws, hostReason = "host_disconnected") { + const clientId = typeof ws?.clientId === "string" ? ws.clientId : ""; + if (!clientId) return; + const streamEnds = []; + for (const [postId, session] of streamSessionsByPostId.entries()) { + if (!session) continue; + if (session.hostClientId === clientId) { + streamEnds.push(postId); + continue; + } + detachViewerFromStream(postId, clientId, true); + } + for (const postId of streamEnds) endStreamSession(postId, hostReason); +} + function setTyping(postId, username, isTyping) { if (!postId || !username) return; let byUser = typingByPostId.get(postId); @@ -2762,6 +2924,7 @@ function setTyping(postId, username, isTyping) { function deletePost(id, reason = "expired") { const entry = posts.get(id); if (!entry) return; + if (streamSessionsByPostId.has(id)) endStreamSession(id, reason === "expired" ? "post_expired" : "post_deleted"); clearTimeout(entry.timer); for (const m of entry.chat) { if (m?.id) chatReactionsByMessageId.delete(m.id); @@ -2777,7 +2940,7 @@ function deletePost(id, reason = "expired") { schedulePersist(); } -function createPost({ content, keywords, ttl, author, lock, collectionId, mode }) { +function createPost({ content, keywords, ttl, author, lock, collectionId, mode, streamKind }) { const createdAt = now(); const isPermanent = Number(ttl || 0) === 0; const ttlMs = isPermanent ? 0 : clampTtl(ttl); @@ -2789,6 +2952,7 @@ function createPost({ content, keywords, ttl, author, lock, collectionId, mode } content: title || "", contentHtml: "", mode: sanitizePostMode(mode), + streamKind: sanitizePostStreamKind(mode, streamKind), readOnly: false, collectionId: getActiveCollectionById(collectionId)?.id || DEFAULT_COLLECTION_ID, keywords: normalizeKeywords(keywords), @@ -2875,6 +3039,7 @@ function markPostDeleted(postId, actor, reason = "", roleOverride = "") { const entry = posts.get(postId); if (!entry) return { ok: false, message: "Post not found." }; if (entry.post.deleted) return { ok: false, message: "Post is already deleted." }; + if (streamSessionsByPostId.has(postId)) endStreamSession(postId, "post_deleted"); if (!entry.post.deletedSnapshot) { const prePost = entry.post || {}; const postReactions = mapSetsToObj(postReactionsByPostId.get(postId)); @@ -2890,6 +3055,8 @@ function markPostDeleted(postId, actor, reason = "", roleOverride = "") { title: prePost.title || "", content: prePost.content || "", contentHtml: prePost.contentHtml || "", + mode: sanitizePostMode(prePost.mode), + streamKind: sanitizePostStreamKind(prePost.mode, prePost.streamKind), collectionId: normalizeCollectionId(prePost.collectionId) || DEFAULT_COLLECTION_ID, keywords: Array.isArray(prePost.keywords) ? [...prePost.keywords] : [], author: prePost.author || null, @@ -2986,6 +3153,8 @@ function restoreDeletedPost(postId) { entry.post.content = typeof p.content === "string" ? p.content.slice(0, POSTS_MAX_CONTENT_LEN) : entry.post.content; const htmlRaw = typeof p.contentHtml === "string" ? p.contentHtml.slice(0, POST_MAX_HTML_LEN) : ""; entry.post.contentHtml = htmlRaw ? sanitizeRichHtml(htmlRaw) : ""; + entry.post.mode = sanitizePostMode(p.mode || entry.post.mode || POST_MODE_TEXT); + entry.post.streamKind = sanitizePostStreamKind(entry.post.mode, p.streamKind || entry.post.streamKind || ""); entry.post.collectionId = normalizeCollectionId(p.collectionId) || entry.post.collectionId || DEFAULT_COLLECTION_ID; entry.post.keywords = normalizeKeywords(p.keywords); entry.post.author = normalizeUsername(p.author || "") || entry.post.author || null; @@ -4356,6 +4525,10 @@ wss.on("connection", (ws, req) => { collections: listCollectionsForClient(ws.user?.username || ""), roles: { custom: listCustomRolesForClient() }, plugins: listPluginsForClient(), + stream: { + enabled: STREAM_ENABLED, + iceServers: STREAM_ICE_SERVERS + }, reactions: { allowed: ALLOWED_REACTIONS, allowedPost: ALLOWED_POST_REACTIONS, allowedChat: ALLOWED_CHAT_REACTIONS }, auth: { loggedIn: false, @@ -4503,6 +4676,7 @@ wss.on("connection", (ws, req) => { if (msg.type === "logout") { const hadUser = Boolean(ws.user?.username); + detachSocketFromStreams(ws, "host_logged_out"); if (ws.sessionId) revokeSessionId(ws.sessionId); ws.user = null; ws.sessionId = ""; @@ -4675,7 +4849,8 @@ wss.on("connection", (ws, req) => { author: ws.user.username, lock, collectionId: selectedCollection.id, - mode: sanitizePostMode(msg.mode) + mode: sanitizePostMode(msg.mode), + streamKind: sanitizePostStreamKind(msg.mode, msg.streamKind) }); // Send per-client serialized view (protected posts are redacted unless unlocked/author) for (const client of sockets) { @@ -4806,6 +4981,197 @@ wss.on("connection", (ws, req) => { return; } + if (msg.type === "streamHostStart") { + if (!STREAM_ENABLED) { + sendError(ws, "Streaming is disabled on this instance."); + return; + } + if (!ws.user?.username) { + sendError(ws, "Please sign in to host a stream."); + return; + } + const guard = enforceUserState(ws, "chat"); + if (!guard.ok) { + sendError(ws, guard.message); + return; + } + const postId = typeof msg.postId === "string" ? msg.postId.trim() : ""; + const entry = posts.get(postId); + if (!entry) { + sendError(ws, "Post not found."); + return; + } + if (!canUserSeePostByCollection(ws.user?.username || "", entry.post)) { + sendError(ws, "You do not have access to this collection."); + return; + } + if (entry.post?.protected && !hasPostAccess(ws, entry.post)) { + sendError(ws, "This post is password protected."); + return; + } + if (entry.post?.deleted) { + sendError(ws, "This post was deleted."); + return; + } + if (sanitizePostMode(entry.post.mode) !== POST_MODE_STREAM) { + sendError(ws, "This hive is not a stream post."); + return; + } + const isAuthor = Boolean(entry.post.author && entry.post.author === ws.user.username); + if (!isAuthor && !hasRole(ws.user.username, ROLE_MODERATOR)) { + sendError(ws, "Only the stream owner or a moderator can go live."); + return; + } + const existing = streamSessionsByPostId.get(postId); + if (existing && existing.hostClientId && existing.hostClientId !== ws.clientId) { + sendError(ws, "Another host is already live in this stream."); + return; + } + if (existing && existing.hostClientId === ws.clientId) { + endStreamSession(postId, "host_restarted"); + } + entry.post.streamKind = sanitizePostStreamKind(POST_MODE_STREAM, msg.streamKind || entry.post.streamKind); + schedulePersist(); + const session = { + postId, + hostClientId: ws.clientId, + hostUsername: ws.user.username, + kind: sanitizePostStreamKind(POST_MODE_STREAM, entry.post.streamKind), + viewers: new Set(), + startedAt: now() + }; + streamSessionsByPostId.set(postId, session); + sendStreamState(postId); + ws.send( + JSON.stringify({ + type: "streamHostStarted", + postId, + kind: session.kind + }) + ); + return; + } + + if (msg.type === "streamHostStop") { + const postId = typeof msg.postId === "string" ? msg.postId.trim() : ""; + const entry = posts.get(postId); + if (!entry) { + sendError(ws, "Post not found."); + return; + } + const session = streamSessionsByPostId.get(postId); + if (!session) { + ws.send(JSON.stringify({ type: "streamHostStopped", postId, alreadyStopped: true })); + return; + } + const canForce = Boolean(ws.user?.username && hasRole(ws.user.username, ROLE_MODERATOR)); + if (session.hostClientId !== ws.clientId && !canForce) { + sendError(ws, "Only the current host can stop this stream."); + return; + } + endStreamSession(postId, "host_stopped"); + ws.send(JSON.stringify({ type: "streamHostStopped", postId })); + return; + } + + if (msg.type === "streamJoin") { + if (!STREAM_ENABLED) { + sendError(ws, "Streaming is disabled on this instance."); + return; + } + const postId = typeof msg.postId === "string" ? msg.postId.trim() : ""; + const entry = posts.get(postId); + if (!entry) { + sendError(ws, "Post not found."); + return; + } + if (!canUserSeePostByCollection(ws.user?.username || "", entry.post)) { + sendError(ws, "You do not have access to this collection."); + return; + } + if (entry.post?.protected && !hasPostAccess(ws, entry.post)) { + sendError(ws, "This post is password protected."); + return; + } + if (entry.post?.deleted) { + sendError(ws, "This post was deleted."); + return; + } + if (sanitizePostMode(entry.post.mode) !== POST_MODE_STREAM) { + sendError(ws, "This hive is not a stream post."); + return; + } + const session = streamSessionsByPostId.get(postId); + if (!session) { + ws.send(JSON.stringify({ type: "streamJoinAck", postId, live: false })); + return; + } + if (session.hostClientId !== ws.clientId) { + session.viewers.add(ws.clientId); + } + ws.send( + JSON.stringify({ + type: "streamJoinAck", + postId, + live: true, + hostClientId: session.hostClientId, + hostUsername: session.hostUsername, + kind: sanitizePostStreamKind(POST_MODE_STREAM, session.kind), + viewerCount: session.viewers.size + }) + ); + if (session.hostClientId !== ws.clientId) { + const host = findSocketByClientId(session.hostClientId); + if (host && host.readyState === host.OPEN) { + host.send( + JSON.stringify({ + type: "streamViewerJoin", + postId, + viewerClientId: ws.clientId, + viewerUsername: normalizeUsername(ws.user?.username || "") || "" + }) + ); + } + } + sendStreamState(postId); + return; + } + + if (msg.type === "streamLeave") { + const postId = typeof msg.postId === "string" ? msg.postId.trim() : ""; + if (!postId) return; + detachViewerFromStream(postId, ws.clientId, true); + return; + } + + if (msg.type === "streamSignal") { + const postId = typeof msg.postId === "string" ? msg.postId.trim() : ""; + const session = streamSessionsByPostId.get(postId); + if (!session) return; + const targetClientId = typeof msg.targetClientId === "string" ? msg.targetClientId.trim() : ""; + if (!targetClientId || targetClientId === ws.clientId) return; + const signal = msg.signal; + if (!signal || typeof signal !== "object") return; + + const senderIsHost = session.hostClientId === ws.clientId; + const senderIsViewer = session.viewers.has(ws.clientId); + if (!senderIsHost && !senderIsViewer) return; + if (senderIsHost && !session.viewers.has(targetClientId)) return; + if (senderIsViewer && targetClientId !== session.hostClientId) return; + + const target = findSocketByClientId(targetClientId); + if (!target || target.readyState !== target.OPEN) return; + target.send( + JSON.stringify({ + type: "streamSignal", + postId, + fromClientId: ws.clientId, + signal + }) + ); + return; + } + if (msg.type === "editPost") { if (!ws.user?.username) { sendError(ws, "Please sign in to edit posts."); @@ -4856,6 +5222,7 @@ wss.on("connection", (ws, req) => { const hasCollectionField = Object.prototype.hasOwnProperty.call(msg, "collectionId"); const hasKeywordsField = Object.prototype.hasOwnProperty.call(msg, "keywords"); const hasModeField = Object.prototype.hasOwnProperty.call(msg, "mode") || Object.prototype.hasOwnProperty.call(msg, "chatMode"); + const hasStreamKindField = Object.prototype.hasOwnProperty.call(msg, "streamKind"); const beforeCollectionId = normalizeCollectionId(entry.post.collectionId || "") || DEFAULT_COLLECTION_ID; const beforeProtected = Boolean(entry.post.protected); @@ -4863,6 +5230,7 @@ wss.on("connection", (ws, req) => { const beforeTitle = entry.post.title || ""; const beforeContent = textPreview(entry.post.content || ""); const beforeMode = sanitizePostMode(entry.post.mode || entry.post.chatMode || ""); + const beforeStreamKind = sanitizePostStreamKind(beforeMode, entry.post.streamKind || ""); if (hasCollectionField) { const requestedCollectionId = normalizeCollectionId(msg.collectionId || ""); @@ -4882,8 +5250,12 @@ wss.on("connection", (ws, req) => { entry.post.keywords = normalizeKeywords(msg.keywords); } - if (hasModeField) { - entry.post.mode = sanitizePostMode(msg.mode || msg.chatMode || ""); + if (hasModeField || hasStreamKindField) { + const nextModeRaw = hasModeField ? msg.mode || msg.chatMode || "" : entry.post.mode || ""; + const nextMode = sanitizePostMode(nextModeRaw || entry.post.mode || ""); + const nextStreamKindRaw = hasStreamKindField ? msg.streamKind : entry.post.streamKind; + entry.post.mode = nextMode; + entry.post.streamKind = sanitizePostStreamKind(nextMode, nextStreamKindRaw); } if (hasProtectedField) { @@ -4917,6 +5289,15 @@ wss.on("connection", (ws, req) => { entry.post.contentHtml = safeHtml; entry.post.editedAt = now(); entry.post.editCount = Math.max(0, Number(entry.post.editCount || 0)) + 1; + if (beforeMode === POST_MODE_STREAM && entry.post.mode !== POST_MODE_STREAM) { + endStreamSession(postId, "mode_changed"); + } else if (entry.post.mode === POST_MODE_STREAM) { + const session = streamSessionsByPostId.get(postId); + if (session) { + session.kind = sanitizePostStreamKind(POST_MODE_STREAM, entry.post.streamKind); + sendStreamState(postId); + } + } schedulePersist(); const logEntry = appendModLog({ actionType: "self_post_edit", @@ -4936,6 +5317,8 @@ wss.on("connection", (ws, req) => { afterKeywords: (entry.post.keywords || []).join(", "), beforeMode, afterMode: sanitizePostMode(entry.post.mode || ""), + beforeStreamKind, + afterStreamKind: sanitizePostStreamKind(entry.post.mode || "", entry.post.streamKind || ""), editCount: entry.post.editCount, editedAt: entry.post.editedAt } @@ -4946,6 +5329,9 @@ wss.on("connection", (ws, req) => { } const visibilityChanged = beforeCollectionId !== (normalizeCollectionId(entry.post.collectionId || "") || DEFAULT_COLLECTION_ID); const protectionChanged = beforeProtected !== Boolean(entry.post.protected); + if ((visibilityChanged || protectionChanged) && streamSessionsByPostId.has(postId)) { + endStreamSession(postId, "post_updated"); + } for (const client of sockets) { if (client.readyState !== client.OPEN) continue; if (visibilityChanged || protectionChanged) { @@ -5487,10 +5873,14 @@ wss.on("connection", (ws, req) => { for (const entry of posts.values()) { if (entry?.timer) clearTimeout(entry.timer); } + for (const [postId] of streamSessionsByPostId.entries()) { + endStreamSession(postId, "board_reset"); + } deletedPosts = posts.size; // Clear in-memory state posts.clear(); + streamSessionsByPostId.clear(); typingByPostId.clear(); postReactionsByPostId.clear(); chatReactionsByMessageId.clear(); @@ -6763,6 +7153,7 @@ wss.on("connection", (ws, req) => { if (byUser.has(ws.user.username)) setTyping(postId, ws.user.username, false); } } + detachSocketFromStreams(ws, "host_disconnected"); // Plugin cleanup hooks. try {