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:
| M | public/app.js | | | 646 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | public/index.html | | | 44 | +++++++++++++++++++++++++++++++++++++++----- |
| M | public/styles.css | | | 57 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | server.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 {