bzl

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

commit 182e174aa422dfa5540fdeff7b511b3e50143623
parent 40e1aaaba2acc433cee8102291687cbff1fbb640
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Fri, 20 Feb 2026 18:17:58 -0700

UX pass: stabilize rack chat flow, compact skinny mode, and shortcut-driven navigation

Reworked rack/workspace behavior with empty-slot + Add panel affordances and stronger panel placement enforcement.
Added compact skinny chat mode for chat panels in side/right racks (denser message rows, reduced controls, hidden heavy media/reaction UI).
Fixed chat context switching:
robust Open chats dropdown population (DMs + hive chats),
unified switch handler for dropdown and -/= cycling,
no unwanted editor focus when switching contexts.
Improved chat discoverability/usability:
chat empty-state action buttons (Open Hives, Open People),
dropdown entries now include unread counts and relative activity times.
Added new user controls in View panel:
Chat send key preference (Ctrl/Cmd+Enter vs Enter send + Shift+Enter newline),
Shortcut help modal (? key),
Reset layout (reapply current preset).
Updated hint copy to document newer shortcuts/workflows.
Synced all runtime changes into CLEAN_INSTALL/public/*.
Bumped asset cache versions to ensure clients pull latest (styles.css?v=130, app.js?v=152).

Diffstat:
MCLEAN_INSTALL/public/app.js | 428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MCLEAN_INSTALL/public/index.html | 42++++++++++++++++++++++++++++++++++++------
MCLEAN_INSTALL/public/styles.css | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpublic/app.js | 428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpublic/index.html | 42++++++++++++++++++++++++++++++++++++------
Mpublic/styles.css | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1084 insertions(+), 98 deletions(-)

diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js @@ -34,6 +34,9 @@ const uiScaleEl = document.getElementById("uiScale"); const deviceLayoutEl = document.getElementById("deviceLayout"); const stayConnectedEl = document.getElementById("stayConnected"); const enableHintsEl = document.getElementById("enableHints"); +const chatEnterModeEl = document.getElementById("chatEnterMode"); +const openShortcutHelpBtn = document.getElementById("openShortcutHelp"); +const resetCurrentLayoutBtn = document.getElementById("resetCurrentLayout"); const dockHotbarEl = document.getElementById("dockHotbar"); const showSideRackBtn = document.getElementById("showSideRack"); const showRightRackBtn = document.getElementById("showRightRack"); @@ -79,6 +82,8 @@ const mediaModalOpenLink = document.getElementById("mediaModalOpenLink"); const mediaModalCopyLink = document.getElementById("mediaModalCopyLink"); const mediaModalClose = document.getElementById("mediaModalClose"); const mediaModalStatus = document.getElementById("mediaModalStatus"); +const shortcutHelpModal = document.getElementById("shortcutHelpModal"); +const shortcutHelpCloseBtn = document.getElementById("shortcutHelpClose"); const newPostForm = document.getElementById("newPostForm"); const pollinatePanel = document.getElementById("pollinatePanel"); @@ -1288,6 +1293,20 @@ function rackIdForPanelElement(panelEl) { return ""; } +function updateSkinnyChatPanels() { + const applySkinnyState = (panelEl) => { + if (!(panelEl instanceof HTMLElement)) return; + const rackId = rackIdForPanelElement(panelEl); + const inSkinnyRack = rackId === "mainSideRack" || rackId === "rightRack"; + panelEl.classList.toggle("isSkinnyChat", Boolean(rackLayoutEnabled && inSkinnyRack)); + }; + + applySkinnyState(chatPanelEl); + for (const panelId of chatPanelInstances.keys()) { + applySkinnyState(getPanelElement(panelId)); + } +} + function rememberPanelLastRack(panelId, rackId) { const id = String(panelId || "").trim(); const rack = String(rackId || "").trim(); @@ -1396,6 +1415,7 @@ function showHotbar(show) { if (!show && dockHotbarEl.dataset.lockVisible === "1") return; dockHotbarEl.classList.toggle("hidden", !show); dockHotbarEl.classList.toggle("show", Boolean(show)); + if (appRoot) appRoot.classList.toggle("hotbarVisible", Boolean(show)); } function renderHotbar() { @@ -1406,6 +1426,7 @@ function renderHotbar() { dockHotbarEl.classList.add("hidden"); dockHotbarEl.classList.remove("show"); dockHotbarEl.innerHTML = ""; + if (appRoot) appRoot.classList.remove("hotbarVisible"); return; } @@ -1435,6 +1456,7 @@ function renderHotbar() { } let hotbarPlusMenuEl = null; +let workspaceAddMenuEl = null; function closeHotbarPlusMenu() { if (!hotbarPlusMenuEl) return; @@ -1446,6 +1468,84 @@ function closeHotbarPlusMenu() { hotbarPlusMenuEl = null; } +function closeWorkspaceAddMenu() { + if (!workspaceAddMenuEl) return; + try { + workspaceAddMenuEl.remove(); + } catch { + // ignore + } + workspaceAddMenuEl = null; +} + +function workspaceAddCandidates() { + return Array.from(panelRegistry.keys()) + .filter((id) => Boolean(getPanelElement(id))) + .filter((id) => !id.startsWith("chat:post:")) + .filter((id) => id !== "profile") + .filter((id) => !(id === "moderation" && !canModerate)) + .map((id) => ({ + id, + title: panelTitle(id), + icon: panelIcon(id), + docked: isDocked(id), + })) + .sort((a, b) => a.title.localeCompare(b.title)); +} + +function restorePanelToWorkspaceSlot(panelId, slotId) { + const id = String(panelId || "").trim(); + const slot = String(slotId || "").trim(); + if (!id || !slot) return; + const target = slot === "workspaceRightSlot" ? ensureWorkspaceRightRack() : ensureWorkspaceLeftRack(); + if (!(target instanceof HTMLElement)) return; + const panelEl = getPanelElement(id); + if (!(panelEl instanceof HTMLElement)) return; + if (isDocked(id)) undockPanel(id); + const existing = target.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + target.appendChild(panelEl); + rememberPanelLastRack(id, target.id); + saveRackLayoutState(); + applyDockState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); +} + +function openWorkspaceAddMenu(anchorEl, slotId) { + closeWorkspaceAddMenu(); + if (!(anchorEl instanceof HTMLElement)) return; + const slot = String(slotId || "").trim(); + if (!slot) return; + const items = workspaceAddCandidates() + .map( + (p) => `<button type="button" class="ghost smallBtn" data-workspaceaddpanel="${escapeHtml(p.id)}" data-workspaceaddslot="${escapeHtml(slot)}"> + ${escapeHtml(p.icon)} ${escapeHtml(p.title)}${p.docked ? " (docked)" : ""} + </button>` + ) + .join(""); + const menu = document.createElement("div"); + menu.className = "hotbarAddMenu"; + menu.innerHTML = `<div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No panels available.</div>`}</div>`; + const rect = anchorEl.getBoundingClientRect(); + menu.style.left = `${Math.max(12, Math.min(window.innerWidth - 272, rect.left - 10))}px`; + menu.style.top = `${Math.max(12, rect.bottom + 8)}px`; + menu.addEventListener("click", (e) => { + const btn = e.target.closest?.("[data-workspaceaddpanel][data-workspaceaddslot]"); + if (!btn) return; + const id = String(btn.getAttribute("data-workspaceaddpanel") || "").trim(); + const slotIdNext = String(btn.getAttribute("data-workspaceaddslot") || "").trim(); + if (!id || !slotIdNext) return; + restorePanelToWorkspaceSlot(id, slotIdNext); + closeWorkspaceAddMenu(); + }); + document.body.appendChild(menu); + workspaceAddMenuEl = menu; +} + function openHotbarPlusMenu(anchorEl) { closeHotbarPlusMenu(); if (!dockHotbarEl) return; @@ -1508,6 +1608,31 @@ function applyDockState() { renderHotbar(); updateSideRackEmptyState(); + updateSkinnyChatPanels(); + renderWorkspaceSlotAffordances(); +} + +function renderWorkspaceSlotAffordances() { + if (!rackLayoutEnabled) return; + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + for (const slot of [left, right]) { + if (!(slot instanceof HTMLElement)) continue; + const hasVisible = Boolean(slot.querySelector?.(":scope > .rackPanel:not(.hidden)")); + slot.classList.toggle("workspaceSlotEmpty", !hasVisible); + const existing = slot.querySelector?.(":scope > .workspaceEmptyAdd"); + if (hasVisible) { + if (existing) existing.remove(); + continue; + } + if (existing) continue; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "workspaceEmptyAdd ghost"; + btn.setAttribute("data-workspaceadd", slot.id || ""); + btn.innerHTML = `<span class="workspaceEmptyAddPlus">+</span><span>Add panel</span>`; + slot.appendChild(btn); + } } function readRackOrder(rackEl) { @@ -1687,6 +1812,8 @@ function enforceWorkspaceRules() { el.dataset.panelDisplay = "collapsed"; } + updateSkinnyChatPanels(); + renderWorkspaceSlotAffordances(); syncRackStateFromDom(); } @@ -1699,6 +1826,14 @@ function installWorkspaceInteractions() { appRoot.addEventListener("click", (e) => { if (!rackLayoutEnabled) return; const target = e.target; + const addBtn = target?.closest?.("[data-workspaceadd]"); + if (addBtn instanceof HTMLElement) { + const slotId = String(addBtn.getAttribute("data-workspaceadd") || "").trim(); + if (!slotId) return; + if (workspaceAddMenuEl) closeWorkspaceAddMenu(); + else openWorkspaceAddMenu(addBtn, slotId); + return; + } const interactive = target?.closest?.("button,a,input,select,textarea,label"); if (interactive) return; const panel = target?.closest?.(".rackPanel"); @@ -2214,8 +2349,7 @@ function ensureChatPostPanelInstance(postId, opts) { }); editorEl.addEventListener("keydown", (e) => { - if (e.key !== "Enter") return; - if (!(e.ctrlKey || e.metaKey)) return; + if (!shouldSubmitChatOnEnter(e)) return; e.preventDefault(); formEl.requestSubmit(); }); @@ -2825,15 +2959,18 @@ function initRackLayout() { if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") { appRoot.dataset.hotbarPlusClose = "1"; document.addEventListener("pointerdown", (e) => { - if (!hotbarPlusMenuEl && !pluginRackAddMenuEl) return; + if (!hotbarPlusMenuEl && !pluginRackAddMenuEl && !workspaceAddMenuEl) return; const t = e.target; if (t) { if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return; if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return; + if (workspaceAddMenuEl && workspaceAddMenuEl.contains(t)) return; if (dockHotbarEl && dockHotbarEl.contains(t)) return; + if (t.closest?.("[data-workspaceadd]")) return; } closeHotbarPlusMenu(); closePluginRackAddMenu(); + closeWorkspaceAddMenu(); }); } @@ -3089,6 +3226,7 @@ function writeStayConnectedPref(on) { writeBoolPref(STAY_CONNECTED_KEY, Boolean(on)); } const ENABLE_HINTS_KEY = "bzl_enableHints"; +const CHAT_ENTER_MODE_KEY = "bzl_chatEnterMode"; // "ctrlEnter" | "enter" function readHintsEnabledPref() { const raw = localStorage.getItem(ENABLE_HINTS_KEY); if (raw == null) return true; @@ -3100,6 +3238,16 @@ function writeHintsEnabledPref(on) { appRoot?.classList.toggle("hintsEnabled", enabled); } +function readChatEnterModePref() { + const raw = readStringPref(CHAT_ENTER_MODE_KEY, "ctrlEnter"); + return raw === "enter" ? "enter" : "ctrlEnter"; +} + +function writeChatEnterModePref(mode) { + const next = String(mode || "").trim().toLowerCase(); + writeStringPref(CHAT_ENTER_MODE_KEY, next === "enter" ? "enter" : "ctrlEnter"); +} + let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} }; let onboardingState = { enabled: true, @@ -3482,6 +3630,31 @@ function dmActivityAt(thread) { return Math.max(Number(thread.lastMessageAt || 0), Number(thread.updatedAt || 0), Number(thread.createdAt || 0)); } +function shortTimeAgo(ts) { + const t = Number(ts || 0); + if (!Number.isFinite(t) || t <= 0) return ""; + const deltaMs = Math.max(0, Date.now() - t); + const mins = Math.floor(deltaMs / 60000); + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + return `${days}d`; +} + +function postChatActivityAt(postId, post) { + const id = String(postId || "").trim(); + const list = id ? chatByPost.get(id) : null; + const lastChatAt = + Array.isArray(list) && list.length + ? Math.max( + ...list.map((m) => Math.max(Number(m?.createdAt || 0), Number(m?.editedAt || 0), Number(m?.deletedAt || 0))) + ) + : 0; + return Math.max(lastChatAt, Number(post?.createdAt || 0), Number(post?.updatedAt || 0)); +} + function pushRecentUnique(list, id, limit = CHAT_RECENTS_LIMIT) { const value = String(id || "").trim(); if (!value) return list; @@ -3508,8 +3681,35 @@ function activeDmThreadsSorted() { .sort((a, b) => dmActivityAt(b) - dmActivityAt(a)); } +function blurFocusedChatComposer() { + const activeEl = document.activeElement; + if (!(activeEl instanceof HTMLElement)) return; + if (activeEl === chatEditor || activeEl.closest?.(".chatEditor")) activeEl.blur(); +} + +function openChatContextValue(rawValue, opts = null) { + const raw = String(rawValue || "").trim(); + if (!raw) return false; + const options = opts && typeof opts === "object" ? opts : {}; + const preserveFocus = Boolean(options.preserveFocus); + if (raw.startsWith("dm:")) { + const id = raw.slice(3); + if (!id) return false; + openDmThread(id, { preserveFocus }); + return true; + } + if (raw.startsWith("post:")) { + const id = raw.slice(5); + if (!id) return false; + openChat(id, { preserveFocus }); + return true; + } + return false; +} + function renderChatContextSelect() { if (!(chatContextSelectEl instanceof HTMLSelectElement)) return; + const prevValue = String(chatContextSelectEl.value || "").trim(); const dmThreadsActive = activeDmThreadsSorted(); const dmById = new Map(dmThreadsActive.map((t) => [t.id, t])); recentDmChatThreadIds = recentDmChatThreadIds.filter((id) => dmById.has(id)); @@ -3526,7 +3726,8 @@ function renderChatContextSelect() { const p = postsById.get(String(id)); return Boolean(p && !p.deleted); }); - const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds] + const knownChatPostIds = Array.from(chatByPost.keys()).map((id) => String(id || "").trim()).filter(Boolean); + const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds, ...knownChatPostIds] .map((id) => postsById.get(String(id || ""))) .filter((p) => p && !p.deleted) .filter((p, i, arr) => arr.findIndex((x) => String(x.id) === String(p.id)) === i); @@ -3540,30 +3741,48 @@ function renderChatContextSelect() { const activeDmValue = activeDmThreadId ? `dm:${activeDmThreadId}` : ""; const activePostValue = activeChatPostId ? `post:${activeChatPostId}` : ""; - const selected = activeDmValue || activePostValue || ""; - - const dmOptions = dmRecent - .map((t) => { - const other = `@${escapeHtml(t.other || "unknown")}`; - return `<option value="dm:${escapeHtml(t.id)}">${other}</option>`; - }) - .join(""); - - const postOptions = postRecent - .map((p) => { - const label = `${escapeHtml(postTitle(p))}${p.author ? ` - @${escapeHtml(String(p.author || ""))}` : ""}`; - return `<option value="post:${escapeHtml(String(p.id))}">${label}</option>`; - }) - .join(""); - - const topPlaceholder = `<option value="">Open chats...</option>`; - const dmGroup = dmOptions ? `<optgroup label="DMs">${dmOptions}</optgroup>` : ""; - const postGroup = postOptions ? `<optgroup label="Hive Chats">${postOptions}</optgroup>` : ""; + const selected = activeDmValue || activePostValue || prevValue; syncingChatContextSelect = true; chatContextSelectEl.classList.remove("hidden"); - chatContextSelectEl.innerHTML = `${topPlaceholder}${dmGroup}${postGroup}`; - chatContextSelectEl.value = selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : ""; + chatContextSelectEl.replaceChildren(); + const topPlaceholder = document.createElement("option"); + topPlaceholder.value = ""; + topPlaceholder.textContent = "Open chats..."; + chatContextSelectEl.appendChild(topPlaceholder); + + if (dmRecent.length) { + const dmGroup = document.createElement("optgroup"); + dmGroup.label = "DMs"; + for (const thread of dmRecent) { + const opt = document.createElement("option"); + opt.value = `dm:${String(thread.id || "").trim()}`; + const when = shortTimeAgo(dmActivityAt(thread)); + opt.textContent = `@${String(thread.other || "unknown")}${when ? ` • ${when}` : ""}`; + dmGroup.appendChild(opt); + } + chatContextSelectEl.appendChild(dmGroup); + } + + if (postRecent.length) { + const postGroup = document.createElement("optgroup"); + postGroup.label = "Hive Chats"; + for (const post of postRecent) { + const postId = String(post.id || "").trim(); + if (!postId) continue; + const opt = document.createElement("option"); + opt.value = `post:${postId}`; + 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 || "")}` : ""}`; + postGroup.appendChild(opt); + } + chatContextSelectEl.appendChild(postGroup); + } + + chatContextSelectEl.value = + selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : ""; syncingChatContextSelect = false; } @@ -3621,6 +3840,11 @@ function setMediaModalOpen(open) { } } +function setShortcutHelpOpen(open) { + if (!shortcutHelpModal) return; + shortcutHelpModal.classList.toggle("hidden", !open); +} + function openMediaModal(url) { const src = String(url || "").trim(); if (!src) return; @@ -4244,6 +4468,9 @@ function renderProfileCard() { const dmBtn = canDm ? `<button type="button" class="primary smallBtn" data-dmrequest="${escapeHtml(p.username)}" ${blocked ? "disabled" : ""}>DM</button>` : ""; + const modDmBtn = canModerate && canDm + ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(p.username)}">Mod DM</button>` + : ""; const member = peopleMembers.find((m) => String(m.username || "").toLowerCase() === usernameLower) || null; const role = roleLabel(member?.role); const isStaff = role === "owner" || role === "moderator"; @@ -4275,7 +4502,7 @@ function renderProfileCard() { <div class="profileHandle" ${p.color ? `style="color:${escapeHtml(safeTextColorFromHex(p.color))}"` : ""}>@${escapeHtml(p.username)}</div> ${pronouns} </div> - ${dmBtn || ignoreBtn || blockBtn ? `<div class="profileActions">${dmBtn}${ignoreBtn}${blockBtn}</div>` : ""} + ${dmBtn || modDmBtn || ignoreBtn || blockBtn ? `<div class="profileActions">${dmBtn}${modDmBtn}${ignoreBtn}${blockBtn}</div>` : ""} </div> ${blockNote} <div class="profileSection"> @@ -6077,6 +6304,21 @@ function renderFeed() { const contentText = typeof p.content === "string" && p.content.trim() ? escapeHtml(p.content) : ""; const content = contentHtml ? contentHtml : contentText ? `<div class="muted">${contentText}</div>` : ""; const contentBlock = content ? `<div class="postContent">${content}</div>` : ""; + const lastChat = (chatByPost.get(p.id) || []).filter((m) => !m?.deleted).slice(-1)[0] || null; + const lastChatFrom = lastChat ? String(lastChat.fromUser || "").trim() : ""; + const lastChatText = lastChat ? String(lastChat.text || "").replace(/\s+/g, " ").trim().slice(0, 92) : ""; + const lastChatWho = lastChat + ? (lastChatFrom && lastChatFrom.toLowerCase() === "mod" ? "MOD" : `@${escapeHtml(lastChatFrom || "unknown")}`) + : ""; + const lastChatLine = lastChat + ? `<div class="small muted postLastChat">Last chat: ${lastChatWho}${lastChatText ? ` — ${escapeHtml(lastChatText)}` : ""}</div>` + : ""; + const typersSet = typingUsersByPostId.get(p.id); + const typingUsers = typersSet ? Array.from(typersSet.values()).slice(0, 2) : []; + const typingMore = typersSet && typersSet.size > typingUsers.length ? ` +${typersSet.size - typingUsers.length}` : ""; + const typingLine = typingUsers.length + ? `<div class="small muted postTypingLine">${typingUsers.map((u) => `@${escapeHtml(u)}`).join(", ")}${typingMore} typing...</div>` + : ""; return ` <article class="post${unreadClass}${newClass}${buzzClass}" data-id="${p.id}" ${cardTint}> @@ -6102,6 +6344,8 @@ function renderFeed() { ${deletedLine} ${editedLine} ${contentBlock} + ${typingLine} + ${lastChatLine} <div class="postMeta">${collectionTag}${tags ? ` ${tags}` : ""}</div> ${reactionsHtml} </article>`; @@ -6506,6 +6750,9 @@ function renderPeoplePanel() { ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}" disabled>Open</button>` : `<span class="muted small">Blocked</span>`; } + if (canModerate && other) { + actions += ` <button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(other)}">Mod DM</button>`; + } return `<div class="dmThreadCard"> <div class="dmThreadTop"> @@ -6549,6 +6796,7 @@ function renderPeoplePanel() { const statusText = `${status}${m.online ? "" : ""}`; const cardStyle = peopleOnlineCardStyle(m); const canDm = Boolean(loggedInUser && username && String(username).toLowerCase() !== String(loggedInUser).toLowerCase()); + const canModDm = Boolean(canModerate && username && String(username).toLowerCase() !== String(loggedInUser || "").toLowerCase()); return `<div class="peopleCard" data-viewprofile="${escapeHtml(username)}" ${cardStyle}> <div class="peopleCardTop"> <div>${renderUserPill(username)} <span class="modStatus">${escapeHtml(role)}</span></div> @@ -6557,6 +6805,7 @@ function renderPeoplePanel() { <div class="peopleCardActions"> <button type="button" class="ghost smallBtn" data-viewprofile="${escapeHtml(username)}">Profile</button> <button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(username)}" ${canDm ? "" : "disabled"}>DM</button> + ${canModDm ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(username)}">Mod DM</button>` : ""} </div> </div>`; }) @@ -7518,7 +7767,12 @@ function renderChatPanel(forceScroll = false) { if (chatPanelEl) chatPanelEl.classList.remove("walkie"); if (walkieBarEl) walkieBarEl.classList.add("hidden"); if (chatForm) chatForm.classList.remove("hidden"); - chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div><div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div>`; + chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div> + <div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div> + <div class="row" style="gap:8px;justify-content:flex-start;margin-top:8px;"> + <button type="button" class="ghost smallBtn" data-chatemptyopen="hives">Open Hives</button> + <button type="button" class="ghost smallBtn" data-chatemptyopen="people">Open People</button> + </div>`; restoreMediaState(chatMessagesEl, mediaState); setReplyToMessage(null); return; @@ -7835,9 +8089,11 @@ function updateActiveChatMeta() { chatMeta.textContent = `${author} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); } -function openDmThread(threadId) { +function openDmThread(threadId, opts = null) { const id = String(threadId || "").trim(); if (!id) return; + const options = opts && typeof opts === "object" ? opts : {}; + if (!options.preserveFocus) blurFocusedChatComposer(); const thread = dmThreadsById.get(id) || null; if (!thread) { pendingOpenDmThreadId = id; @@ -7865,10 +8121,35 @@ function openDmThread(threadId) { } } +function sendModDmPrompt(rawUsername) { + const to = String(rawUsername || "") + .trim() + .replace(/^@+/, "") + .toLowerCase(); + if (!to) return; + if (!loggedInUser) { + toast("Sign in required", "Sign in to send moderator DMs."); + return; + } + if (!canModerate) { + toast("Moderator only", "You need moderator permissions."); + return; + } + if (to === String(loggedInUser).toLowerCase()) { + toast("Unavailable", "Can't send a moderator DM to yourself."); + return; + } + const text = String(prompt(`Send moderator DM to @${to}:`) || "").trim(); + if (!text) return; + ws.send(JSON.stringify({ type: "dmSendMod", to, text })); + toast("Moderator DM", `Sent to @${to}.`); +} + function openChat(postId, opts = null) { activeDmThreadId = null; stopWalkieRecording(); const options = opts && typeof opts === "object" ? opts : {}; + if (!options.preserveFocus) blurFocusedChatComposer(); const sourceEl = options.sourceEl instanceof HTMLElement ? options.sourceEl : null; const post = posts.get(postId); if (!post) return; @@ -8211,6 +8492,13 @@ function isTextEntryFocused() { return Boolean(el.isContentEditable); } +function shouldSubmitChatOnEnter(evt) { + if (!evt || evt.key !== "Enter") return false; + const mode = readChatEnterModePref(); + if (mode === "enter") return !(evt.shiftKey || evt.altKey || evt.ctrlKey || evt.metaKey); + return Boolean(evt.ctrlKey || evt.metaKey); +} + function cycleLayoutPresetBy(step) { if (!layoutPresetEl || !rackLayoutEnabled || layoutPresetEl.disabled) return; const options = Array.from(layoutPresetEl.options || []) @@ -8297,12 +8585,10 @@ function cycleChatContextBy(step) { return true; } if (next.startsWith("dm:")) { - openDmThread(next.slice(3)); - return true; + return openChatContextValue(next, { preserveFocus: false }); } if (next.startsWith("post:")) { - openChat(next.slice(5)); - return true; + return openChatContextValue(next, { preserveFocus: false }); } return false; } @@ -9156,6 +9442,11 @@ window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => { if (e.defaultPrevented) return; if (e.repeat) return; + if (e.key === "?" && !isTextEntryFocused()) { + e.preventDefault(); + setShortcutHelpOpen(true); + return; + } if (e.altKey || e.ctrlKey || e.metaKey) return; if (isTextEntryFocused()) return; const ctx = activePanelContextForHotkeys(); @@ -9201,6 +9492,26 @@ window.addEventListener("click", (e) => { }); chatMessagesEl.addEventListener("click", (e) => { + const emptyActionBtn = e.target.closest("button[data-chatemptyopen]"); + if (emptyActionBtn) { + const target = String(emptyActionBtn.getAttribute("data-chatemptyopen") || "").trim().toLowerCase(); + if (target === "hives") { + if (isMobileSwipeMode()) { + setMobilePanel("hives"); + } else { + const hivesHeader = hivesPanelEl?.querySelector?.(".panelHeader"); + hivesHeader?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); + } + return; + } + if (target === "people") { + const peopleEl = getPanelElement("people") || peopleDrawerEl; + if (peopleEl && typeof undockPanel === "function" && isDocked("people")) undockPanel("people"); + peopleEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); + return; + } + } + const mobileChatOpenBtn = e.target.closest("button[data-mobilechatopen]"); if (mobileChatOpenBtn) { const postId = mobileChatOpenBtn.getAttribute("data-mobilechatopen") || ""; @@ -9333,15 +9644,7 @@ chatContextSelectEl?.addEventListener("change", () => { if (syncingChatContextSelect) return; const raw = String(chatContextSelectEl.value || "").trim(); if (!raw) return; - if (raw.startsWith("dm:")) { - const id = raw.slice(3); - if (id) openDmThread(id); - return; - } - if (raw.startsWith("post:")) { - const id = raw.slice(5); - if (id) openChat(id); - } + openChatContextValue(raw, { preserveFocus: false }); }); modPanelEl?.addEventListener("click", (e) => { @@ -10088,7 +10391,7 @@ chatEditor.addEventListener("keydown", (e) => { } } if (e.key !== "Enter") return; - if (!(e.ctrlKey || e.metaKey)) return; + if (!shouldSubmitChatOnEnter(e)) return; e.preventDefault(); submitChat(); }); @@ -11130,6 +11433,20 @@ if (enableHintsEl) { writeHintsEnabledPref(Boolean(enableHintsEl.checked)); }); } +if (chatEnterModeEl) { + chatEnterModeEl.value = readChatEnterModePref(); + chatEnterModeEl.addEventListener("change", () => { + writeChatEnterModePref(chatEnterModeEl.value); + }); +} +if (resetCurrentLayoutBtn) { + resetCurrentLayoutBtn.addEventListener("click", () => { + if (!rackLayoutEnabled) return; + const currentPreset = String(rackLayoutState?.presetId || layoutPresetEl?.value || "defaultSocial"); + applyPreset(currentPreset); + toast("Layout", "Current preset layout reset."); + }); +} renderPeoplePanel(); setPeopleOpen(getPeopleOpen()); composerOpen = getComposerOpen(); @@ -11218,8 +11535,18 @@ mediaModalCopyLink?.addEventListener("click", async () => { if (mediaModalStatus) mediaModalStatus.textContent = "Copy failed (clipboard blocked)."; } }); +shortcutHelpModal?.addEventListener("click", (e) => { + if (e.target?.getAttribute?.("data-shortcutclose")) setShortcutHelpOpen(false); +}); +shortcutHelpCloseBtn?.addEventListener("click", () => setShortcutHelpOpen(false)); +openShortcutHelpBtn?.addEventListener("click", () => setShortcutHelpOpen(true)); document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && mediaModal && !mediaModal.classList.contains("hidden")) setMediaModalOpen(false); + if (e.key !== "Escape") return; + if (mediaModal && !mediaModal.classList.contains("hidden")) { + setMediaModalOpen(false); + return; + } + if (shortcutHelpModal && !shortcutHelpModal.classList.contains("hidden")) setShortcutHelpOpen(false); }); document.body.addEventListener("click", (e) => { const img = e.target?.closest?.("img"); @@ -11253,6 +11580,11 @@ peopleDmsTabBtn?.addEventListener("click", () => { }); peopleSearchEl?.addEventListener("input", () => renderPeoplePanel()); peopleListEl?.addEventListener("click", (e) => { + const modDmBtn = e.target.closest("button[data-moddm]"); + if (modDmBtn) { + sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); + return; + } const dmBtn = e.target.closest("button[data-dmrequest]"); if (dmBtn) { const to = String(dmBtn.getAttribute("data-dmrequest") || "") @@ -11277,6 +11609,11 @@ peopleListEl?.addEventListener("click", (e) => { }); peopleDmsViewEl?.addEventListener("click", (e) => { + const modDmBtn = e.target.closest("button[data-moddm]"); + if (modDmBtn) { + sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); + return; + } const profileLink = e.target.closest("[data-viewprofile]"); if (profileLink) { const username = profileLink.getAttribute("data-viewprofile") || ""; @@ -11374,6 +11711,11 @@ onboardingPanelBodyEl?.addEventListener("click", (e) => { }); profileCard?.addEventListener("click", (e) => { + const modDmBtn = e.target.closest("button[data-moddm]"); + if (modDmBtn) { + sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); + return; + } const dmBtn = e.target.closest("button[data-dmrequest]"); if (!dmBtn) return; const to = String(dmBtn.getAttribute("data-dmrequest") || "") diff --git a/CLEAN_INSTALL/public/index.html b/CLEAN_INSTALL/public/index.html @@ -4,7 +4,7 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Bzl - Hives</title> - <link rel="stylesheet" href="/styles.css?v=126" /> + <link rel="stylesheet" href="/styles.css?v=130" /> </head> <body> <div class="app"> @@ -28,7 +28,7 @@ <section id="viewPanel" class="panel"> <div class="panelTitle">View</div> - <div class="uiHint">Use layout presets for quick panel setups. Shortcuts: <b>[</b>/<b>]</b> cycle presets, <b>-</b>/<b>=</b> cycle hives/chats in the active panel.</div> + <div class="uiHint">Use layout presets for quick panel setups. Shortcuts: <b>[</b>/<b>]</b> cycle presets, <b>-</b>/<b>=</b> cycle hives/chats in the active panel, <b>?</b> opens shortcut help.</div> <label class="checkRow" style="margin-top:8px;"> <span>Rack layout (experimental)</span> <input id="toggleRackLayout" type="checkbox" /> @@ -66,6 +66,17 @@ <span>Enable hints</span> <input id="enableHints" type="checkbox" /> </label> + <label style="margin-top:10px;"> + <span>Chat send key</span> + <select id="chatEnterMode"> + <option value="ctrlEnter">Ctrl/Cmd + Enter sends</option> + <option value="enter">Enter sends (Shift+Enter newline)</option> + </select> + </label> + <div class="row" style="margin-top:8px; gap:8px; flex-wrap:wrap;"> + <button id="openShortcutHelp" class="ghost smallBtn" type="button">Shortcut help</button> + <button id="resetCurrentLayout" class="ghost smallBtn" type="button">Reset layout</button> + </div> <details style="margin-top:10px;"> <summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary> @@ -196,7 +207,7 @@ <button id="toggleComposer" class="mobileComposerToggle" type="button">New Hive</button> </div> </div> - <div class="uiHint">Use filters to narrow posts, then tap <b>Chat</b> on a hive card. Shortcut in Hives: <b>-</b>/<b>=</b> cycles collections/views.</div> + <div class="uiHint">Use filters to narrow posts, then tap <b>Chat</b> on a hive card. Cards now show latest chat/typing. Shortcut: <b>-</b>/<b>=</b> cycles collections/views.</div> <div class="hiveTabs" id="hiveTabs"> <button type="button" data-hiveview="all" class="primary">All</button> <button type="button" data-hiveview="starred" class="ghost">Starred</button> @@ -451,14 +462,14 @@ <button id="peopleDmsTab" class="ghost" type="button">DMs</button> </div> <div id="peopleMembersView"> - <div class="uiHint">Members list lets you open profiles and start DMs. Search filters by username.</div> + <div class="uiHint">Members list lets you open profiles and start DMs. Mods can also send <b>Mod DM</b> from member/profile actions.</div> <div class="peopleFilters"> <input id="peopleSearch" placeholder="Search members" /> </div> <div id="peopleList" class="peopleList small"></div> </div> <div id="peopleDmsView" class="peopleDms small hidden"> - <div class="uiHint">DM requests must be accepted before chat opens. Active threads show an <b>Open</b> button.</div> + <div class="uiHint">DM requests must be accepted before chat opens. Active threads show <b>Open</b>, and mods get a <b>Mod DM</b> action.</div> DMs coming soon. </div> </aside> @@ -599,7 +610,26 @@ </div> </div> + <div id="shortcutHelpModal" class="modal hidden" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts"> + <div class="modalBackdrop" data-shortcutclose="1"></div> + <div class="modalCard panel"> + <div class="panelHeader"> + <div class="panelTitle">Keyboard Shortcuts</div> + <div class="row"> + <button id="shortcutHelpClose" class="ghost smallBtn" type="button">Close</button> + </div> + </div> + <div class="modalBody shortcutHelpBody"> + <div><span class="tag">[ / ]</span> Cycle layout presets</div> + <div><span class="tag">- / =</span> Cycle hives or chats in active panel</div> + <div><span class="tag">?</span> Open this shortcut help</div> + <div><span class="tag">`</span> Hold for walkie talkie (when enabled)</div> + <div><span class="tag">Esc</span> Close menus/modals</div> + </div> + </div> + </div> + <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> - <script src="/app.js?v=146"></script> + <script src="/app.js?v=152"></script> </body> </html> diff --git a/CLEAN_INSTALL/public/styles.css b/CLEAN_INSTALL/public/styles.css @@ -773,6 +773,35 @@ body { overflow: hidden; } +.workspaceSlot.workspaceSlotEmpty { + border: 1px dashed color-mix(in srgb, var(--text) 16%, transparent); + border-radius: 14px; + background: color-mix(in srgb, var(--panel) 88%, transparent); + align-items: center; + justify-content: center; +} + +.workspaceEmptyAdd { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + padding: 10px 14px; + border: 1px dashed color-mix(in srgb, var(--accent) 30%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.workspaceEmptyAddPlus { + width: 20px; + height: 20px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--accent) 24%, transparent); + font-weight: 900; +} + .workspaceSlot > .rackPanel { flex: 1; min-height: 0; @@ -1086,6 +1115,12 @@ body { justify-content: flex-end; } +.shortcutHelpBody { + display: flex; + flex-direction: column; + gap: 10px; +} + .editMetaGrid { display: grid; grid-template-columns: 1fr 1fr; @@ -2207,6 +2242,11 @@ button:disabled { margin-top: 10px; } +.postLastChat, +.postTypingLine { + margin-top: 6px; +} + .reactionsRow { display: flex; gap: 6px; @@ -2777,10 +2817,91 @@ button:disabled { border-top: 1px solid var(--line); } +.app.hotbarVisible .chatForm { + padding-bottom: 76px; +} + .chatForm > .primary[type="submit"] { align-self: flex-end; } +.chat.isSkinnyChat .chatMessages { + padding: 8px; +} + +.chat.isSkinnyChat .chatMsg { + width: auto; + max-width: 100%; + margin-left: 0; + margin-right: 0; + border-radius: 10px; + padding: 6px 8px; +} + +.chat.isSkinnyChat .chatMsg.railLeft, +.chat.isSkinnyChat .chatMsg.railCenter, +.chat.isSkinnyChat .chatMsg.railRight { + margin-left: 0; + margin-right: 0; + max-width: 100%; +} + +.chat.isSkinnyChat .chatMsg .meta { + font-size: 10px; + margin-bottom: 2px; +} + +.chat.isSkinnyChat .chatMsg .content { + font-size: 12px; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat.isSkinnyChat .chatMsg .content img, +.chat.isSkinnyChat .chatMsg .content audio, +.chat.isSkinnyChat .chatMsg .content video, +.chat.isSkinnyChat .chatReplyRef, +.chat.isSkinnyChat .chatReactions { + display: none !important; +} + +.chat.isSkinnyChat .chatActionsRow { + margin-top: 4px; + gap: 6px; +} + +.chat.isSkinnyChat .chatTools { + margin-top: 0; + gap: 4px; + justify-content: flex-start; +} + +.chat.isSkinnyChat .chatTools .smallBtn { + padding: 3px 8px; +} + +.chat.isSkinnyChat .toolbar { + padding: 6px; + gap: 4px; +} + +.chat.isSkinnyChat .toolbar .sep, +.chat.isSkinnyChat .toolbar button[data-chatcmd], +.chat.isSkinnyChat .toolbar button[data-chatemoji] { + display: none; +} + +.chat.isSkinnyChat .chatEditor { + min-height: 64px; +} + +.chat.isSkinnyChat .chatForm { + gap: 8px; + padding: 8px; +} + .chatReplyBanner { border: 1px solid rgba(246, 240, 255, 0.16); border-radius: 12px; diff --git a/public/app.js b/public/app.js @@ -34,6 +34,9 @@ const uiScaleEl = document.getElementById("uiScale"); const deviceLayoutEl = document.getElementById("deviceLayout"); const stayConnectedEl = document.getElementById("stayConnected"); const enableHintsEl = document.getElementById("enableHints"); +const chatEnterModeEl = document.getElementById("chatEnterMode"); +const openShortcutHelpBtn = document.getElementById("openShortcutHelp"); +const resetCurrentLayoutBtn = document.getElementById("resetCurrentLayout"); const dockHotbarEl = document.getElementById("dockHotbar"); const showSideRackBtn = document.getElementById("showSideRack"); const showRightRackBtn = document.getElementById("showRightRack"); @@ -79,6 +82,8 @@ const mediaModalOpenLink = document.getElementById("mediaModalOpenLink"); const mediaModalCopyLink = document.getElementById("mediaModalCopyLink"); const mediaModalClose = document.getElementById("mediaModalClose"); const mediaModalStatus = document.getElementById("mediaModalStatus"); +const shortcutHelpModal = document.getElementById("shortcutHelpModal"); +const shortcutHelpCloseBtn = document.getElementById("shortcutHelpClose"); const newPostForm = document.getElementById("newPostForm"); const pollinatePanel = document.getElementById("pollinatePanel"); @@ -1288,6 +1293,20 @@ function rackIdForPanelElement(panelEl) { return ""; } +function updateSkinnyChatPanels() { + const applySkinnyState = (panelEl) => { + if (!(panelEl instanceof HTMLElement)) return; + const rackId = rackIdForPanelElement(panelEl); + const inSkinnyRack = rackId === "mainSideRack" || rackId === "rightRack"; + panelEl.classList.toggle("isSkinnyChat", Boolean(rackLayoutEnabled && inSkinnyRack)); + }; + + applySkinnyState(chatPanelEl); + for (const panelId of chatPanelInstances.keys()) { + applySkinnyState(getPanelElement(panelId)); + } +} + function rememberPanelLastRack(panelId, rackId) { const id = String(panelId || "").trim(); const rack = String(rackId || "").trim(); @@ -1396,6 +1415,7 @@ function showHotbar(show) { if (!show && dockHotbarEl.dataset.lockVisible === "1") return; dockHotbarEl.classList.toggle("hidden", !show); dockHotbarEl.classList.toggle("show", Boolean(show)); + if (appRoot) appRoot.classList.toggle("hotbarVisible", Boolean(show)); } function renderHotbar() { @@ -1406,6 +1426,7 @@ function renderHotbar() { dockHotbarEl.classList.add("hidden"); dockHotbarEl.classList.remove("show"); dockHotbarEl.innerHTML = ""; + if (appRoot) appRoot.classList.remove("hotbarVisible"); return; } @@ -1435,6 +1456,7 @@ function renderHotbar() { } let hotbarPlusMenuEl = null; +let workspaceAddMenuEl = null; function closeHotbarPlusMenu() { if (!hotbarPlusMenuEl) return; @@ -1446,6 +1468,84 @@ function closeHotbarPlusMenu() { hotbarPlusMenuEl = null; } +function closeWorkspaceAddMenu() { + if (!workspaceAddMenuEl) return; + try { + workspaceAddMenuEl.remove(); + } catch { + // ignore + } + workspaceAddMenuEl = null; +} + +function workspaceAddCandidates() { + return Array.from(panelRegistry.keys()) + .filter((id) => Boolean(getPanelElement(id))) + .filter((id) => !id.startsWith("chat:post:")) + .filter((id) => id !== "profile") + .filter((id) => !(id === "moderation" && !canModerate)) + .map((id) => ({ + id, + title: panelTitle(id), + icon: panelIcon(id), + docked: isDocked(id), + })) + .sort((a, b) => a.title.localeCompare(b.title)); +} + +function restorePanelToWorkspaceSlot(panelId, slotId) { + const id = String(panelId || "").trim(); + const slot = String(slotId || "").trim(); + if (!id || !slot) return; + const target = slot === "workspaceRightSlot" ? ensureWorkspaceRightRack() : ensureWorkspaceLeftRack(); + if (!(target instanceof HTMLElement)) return; + const panelEl = getPanelElement(id); + if (!(panelEl instanceof HTMLElement)) return; + if (isDocked(id)) undockPanel(id); + const existing = target.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + target.appendChild(panelEl); + rememberPanelLastRack(id, target.id); + saveRackLayoutState(); + applyDockState(); + syncRackStateFromDom(); + enforceWorkspaceRules(); +} + +function openWorkspaceAddMenu(anchorEl, slotId) { + closeWorkspaceAddMenu(); + if (!(anchorEl instanceof HTMLElement)) return; + const slot = String(slotId || "").trim(); + if (!slot) return; + const items = workspaceAddCandidates() + .map( + (p) => `<button type="button" class="ghost smallBtn" data-workspaceaddpanel="${escapeHtml(p.id)}" data-workspaceaddslot="${escapeHtml(slot)}"> + ${escapeHtml(p.icon)} ${escapeHtml(p.title)}${p.docked ? " (docked)" : ""} + </button>` + ) + .join(""); + const menu = document.createElement("div"); + menu.className = "hotbarAddMenu"; + menu.innerHTML = `<div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No panels available.</div>`}</div>`; + const rect = anchorEl.getBoundingClientRect(); + menu.style.left = `${Math.max(12, Math.min(window.innerWidth - 272, rect.left - 10))}px`; + menu.style.top = `${Math.max(12, rect.bottom + 8)}px`; + menu.addEventListener("click", (e) => { + const btn = e.target.closest?.("[data-workspaceaddpanel][data-workspaceaddslot]"); + if (!btn) return; + const id = String(btn.getAttribute("data-workspaceaddpanel") || "").trim(); + const slotIdNext = String(btn.getAttribute("data-workspaceaddslot") || "").trim(); + if (!id || !slotIdNext) return; + restorePanelToWorkspaceSlot(id, slotIdNext); + closeWorkspaceAddMenu(); + }); + document.body.appendChild(menu); + workspaceAddMenuEl = menu; +} + function openHotbarPlusMenu(anchorEl) { closeHotbarPlusMenu(); if (!dockHotbarEl) return; @@ -1508,6 +1608,31 @@ function applyDockState() { renderHotbar(); updateSideRackEmptyState(); + updateSkinnyChatPanels(); + renderWorkspaceSlotAffordances(); +} + +function renderWorkspaceSlotAffordances() { + if (!rackLayoutEnabled) return; + const left = ensureWorkspaceLeftRack(); + const right = ensureWorkspaceRightRack(); + for (const slot of [left, right]) { + if (!(slot instanceof HTMLElement)) continue; + const hasVisible = Boolean(slot.querySelector?.(":scope > .rackPanel:not(.hidden)")); + slot.classList.toggle("workspaceSlotEmpty", !hasVisible); + const existing = slot.querySelector?.(":scope > .workspaceEmptyAdd"); + if (hasVisible) { + if (existing) existing.remove(); + continue; + } + if (existing) continue; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "workspaceEmptyAdd ghost"; + btn.setAttribute("data-workspaceadd", slot.id || ""); + btn.innerHTML = `<span class="workspaceEmptyAddPlus">+</span><span>Add panel</span>`; + slot.appendChild(btn); + } } function readRackOrder(rackEl) { @@ -1687,6 +1812,8 @@ function enforceWorkspaceRules() { el.dataset.panelDisplay = "collapsed"; } + updateSkinnyChatPanels(); + renderWorkspaceSlotAffordances(); syncRackStateFromDom(); } @@ -1699,6 +1826,14 @@ function installWorkspaceInteractions() { appRoot.addEventListener("click", (e) => { if (!rackLayoutEnabled) return; const target = e.target; + const addBtn = target?.closest?.("[data-workspaceadd]"); + if (addBtn instanceof HTMLElement) { + const slotId = String(addBtn.getAttribute("data-workspaceadd") || "").trim(); + if (!slotId) return; + if (workspaceAddMenuEl) closeWorkspaceAddMenu(); + else openWorkspaceAddMenu(addBtn, slotId); + return; + } const interactive = target?.closest?.("button,a,input,select,textarea,label"); if (interactive) return; const panel = target?.closest?.(".rackPanel"); @@ -2214,8 +2349,7 @@ function ensureChatPostPanelInstance(postId, opts) { }); editorEl.addEventListener("keydown", (e) => { - if (e.key !== "Enter") return; - if (!(e.ctrlKey || e.metaKey)) return; + if (!shouldSubmitChatOnEnter(e)) return; e.preventDefault(); formEl.requestSubmit(); }); @@ -2825,15 +2959,18 @@ function initRackLayout() { if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") { appRoot.dataset.hotbarPlusClose = "1"; document.addEventListener("pointerdown", (e) => { - if (!hotbarPlusMenuEl && !pluginRackAddMenuEl) return; + if (!hotbarPlusMenuEl && !pluginRackAddMenuEl && !workspaceAddMenuEl) return; const t = e.target; if (t) { if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return; if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return; + if (workspaceAddMenuEl && workspaceAddMenuEl.contains(t)) return; if (dockHotbarEl && dockHotbarEl.contains(t)) return; + if (t.closest?.("[data-workspaceadd]")) return; } closeHotbarPlusMenu(); closePluginRackAddMenu(); + closeWorkspaceAddMenu(); }); } @@ -3089,6 +3226,7 @@ function writeStayConnectedPref(on) { writeBoolPref(STAY_CONNECTED_KEY, Boolean(on)); } const ENABLE_HINTS_KEY = "bzl_enableHints"; +const CHAT_ENTER_MODE_KEY = "bzl_chatEnterMode"; // "ctrlEnter" | "enter" function readHintsEnabledPref() { const raw = localStorage.getItem(ENABLE_HINTS_KEY); if (raw == null) return true; @@ -3100,6 +3238,16 @@ function writeHintsEnabledPref(on) { appRoot?.classList.toggle("hintsEnabled", enabled); } +function readChatEnterModePref() { + const raw = readStringPref(CHAT_ENTER_MODE_KEY, "ctrlEnter"); + return raw === "enter" ? "enter" : "ctrlEnter"; +} + +function writeChatEnterModePref(mode) { + const next = String(mode || "").trim().toLowerCase(); + writeStringPref(CHAT_ENTER_MODE_KEY, next === "enter" ? "enter" : "ctrlEnter"); +} + let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} }; let onboardingState = { enabled: true, @@ -3482,6 +3630,31 @@ function dmActivityAt(thread) { return Math.max(Number(thread.lastMessageAt || 0), Number(thread.updatedAt || 0), Number(thread.createdAt || 0)); } +function shortTimeAgo(ts) { + const t = Number(ts || 0); + if (!Number.isFinite(t) || t <= 0) return ""; + const deltaMs = Math.max(0, Date.now() - t); + const mins = Math.floor(deltaMs / 60000); + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + return `${days}d`; +} + +function postChatActivityAt(postId, post) { + const id = String(postId || "").trim(); + const list = id ? chatByPost.get(id) : null; + const lastChatAt = + Array.isArray(list) && list.length + ? Math.max( + ...list.map((m) => Math.max(Number(m?.createdAt || 0), Number(m?.editedAt || 0), Number(m?.deletedAt || 0))) + ) + : 0; + return Math.max(lastChatAt, Number(post?.createdAt || 0), Number(post?.updatedAt || 0)); +} + function pushRecentUnique(list, id, limit = CHAT_RECENTS_LIMIT) { const value = String(id || "").trim(); if (!value) return list; @@ -3508,8 +3681,35 @@ function activeDmThreadsSorted() { .sort((a, b) => dmActivityAt(b) - dmActivityAt(a)); } +function blurFocusedChatComposer() { + const activeEl = document.activeElement; + if (!(activeEl instanceof HTMLElement)) return; + if (activeEl === chatEditor || activeEl.closest?.(".chatEditor")) activeEl.blur(); +} + +function openChatContextValue(rawValue, opts = null) { + const raw = String(rawValue || "").trim(); + if (!raw) return false; + const options = opts && typeof opts === "object" ? opts : {}; + const preserveFocus = Boolean(options.preserveFocus); + if (raw.startsWith("dm:")) { + const id = raw.slice(3); + if (!id) return false; + openDmThread(id, { preserveFocus }); + return true; + } + if (raw.startsWith("post:")) { + const id = raw.slice(5); + if (!id) return false; + openChat(id, { preserveFocus }); + return true; + } + return false; +} + function renderChatContextSelect() { if (!(chatContextSelectEl instanceof HTMLSelectElement)) return; + const prevValue = String(chatContextSelectEl.value || "").trim(); const dmThreadsActive = activeDmThreadsSorted(); const dmById = new Map(dmThreadsActive.map((t) => [t.id, t])); recentDmChatThreadIds = recentDmChatThreadIds.filter((id) => dmById.has(id)); @@ -3526,7 +3726,8 @@ function renderChatContextSelect() { const p = postsById.get(String(id)); return Boolean(p && !p.deleted); }); - const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds] + const knownChatPostIds = Array.from(chatByPost.keys()).map((id) => String(id || "").trim()).filter(Boolean); + const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds, ...knownChatPostIds] .map((id) => postsById.get(String(id || ""))) .filter((p) => p && !p.deleted) .filter((p, i, arr) => arr.findIndex((x) => String(x.id) === String(p.id)) === i); @@ -3540,30 +3741,48 @@ function renderChatContextSelect() { const activeDmValue = activeDmThreadId ? `dm:${activeDmThreadId}` : ""; const activePostValue = activeChatPostId ? `post:${activeChatPostId}` : ""; - const selected = activeDmValue || activePostValue || ""; - - const dmOptions = dmRecent - .map((t) => { - const other = `@${escapeHtml(t.other || "unknown")}`; - return `<option value="dm:${escapeHtml(t.id)}">${other}</option>`; - }) - .join(""); - - const postOptions = postRecent - .map((p) => { - const label = `${escapeHtml(postTitle(p))}${p.author ? ` - @${escapeHtml(String(p.author || ""))}` : ""}`; - return `<option value="post:${escapeHtml(String(p.id))}">${label}</option>`; - }) - .join(""); - - const topPlaceholder = `<option value="">Open chats...</option>`; - const dmGroup = dmOptions ? `<optgroup label="DMs">${dmOptions}</optgroup>` : ""; - const postGroup = postOptions ? `<optgroup label="Hive Chats">${postOptions}</optgroup>` : ""; + const selected = activeDmValue || activePostValue || prevValue; syncingChatContextSelect = true; chatContextSelectEl.classList.remove("hidden"); - chatContextSelectEl.innerHTML = `${topPlaceholder}${dmGroup}${postGroup}`; - chatContextSelectEl.value = selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : ""; + chatContextSelectEl.replaceChildren(); + const topPlaceholder = document.createElement("option"); + topPlaceholder.value = ""; + topPlaceholder.textContent = "Open chats..."; + chatContextSelectEl.appendChild(topPlaceholder); + + if (dmRecent.length) { + const dmGroup = document.createElement("optgroup"); + dmGroup.label = "DMs"; + for (const thread of dmRecent) { + const opt = document.createElement("option"); + opt.value = `dm:${String(thread.id || "").trim()}`; + const when = shortTimeAgo(dmActivityAt(thread)); + opt.textContent = `@${String(thread.other || "unknown")}${when ? ` • ${when}` : ""}`; + dmGroup.appendChild(opt); + } + chatContextSelectEl.appendChild(dmGroup); + } + + if (postRecent.length) { + const postGroup = document.createElement("optgroup"); + postGroup.label = "Hive Chats"; + for (const post of postRecent) { + const postId = String(post.id || "").trim(); + if (!postId) continue; + const opt = document.createElement("option"); + opt.value = `post:${postId}`; + 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 || "")}` : ""}`; + postGroup.appendChild(opt); + } + chatContextSelectEl.appendChild(postGroup); + } + + chatContextSelectEl.value = + selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : ""; syncingChatContextSelect = false; } @@ -3621,6 +3840,11 @@ function setMediaModalOpen(open) { } } +function setShortcutHelpOpen(open) { + if (!shortcutHelpModal) return; + shortcutHelpModal.classList.toggle("hidden", !open); +} + function openMediaModal(url) { const src = String(url || "").trim(); if (!src) return; @@ -4244,6 +4468,9 @@ function renderProfileCard() { const dmBtn = canDm ? `<button type="button" class="primary smallBtn" data-dmrequest="${escapeHtml(p.username)}" ${blocked ? "disabled" : ""}>DM</button>` : ""; + const modDmBtn = canModerate && canDm + ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(p.username)}">Mod DM</button>` + : ""; const member = peopleMembers.find((m) => String(m.username || "").toLowerCase() === usernameLower) || null; const role = roleLabel(member?.role); const isStaff = role === "owner" || role === "moderator"; @@ -4275,7 +4502,7 @@ function renderProfileCard() { <div class="profileHandle" ${p.color ? `style="color:${escapeHtml(safeTextColorFromHex(p.color))}"` : ""}>@${escapeHtml(p.username)}</div> ${pronouns} </div> - ${dmBtn || ignoreBtn || blockBtn ? `<div class="profileActions">${dmBtn}${ignoreBtn}${blockBtn}</div>` : ""} + ${dmBtn || modDmBtn || ignoreBtn || blockBtn ? `<div class="profileActions">${dmBtn}${modDmBtn}${ignoreBtn}${blockBtn}</div>` : ""} </div> ${blockNote} <div class="profileSection"> @@ -6077,6 +6304,21 @@ function renderFeed() { const contentText = typeof p.content === "string" && p.content.trim() ? escapeHtml(p.content) : ""; const content = contentHtml ? contentHtml : contentText ? `<div class="muted">${contentText}</div>` : ""; const contentBlock = content ? `<div class="postContent">${content}</div>` : ""; + const lastChat = (chatByPost.get(p.id) || []).filter((m) => !m?.deleted).slice(-1)[0] || null; + const lastChatFrom = lastChat ? String(lastChat.fromUser || "").trim() : ""; + const lastChatText = lastChat ? String(lastChat.text || "").replace(/\s+/g, " ").trim().slice(0, 92) : ""; + const lastChatWho = lastChat + ? (lastChatFrom && lastChatFrom.toLowerCase() === "mod" ? "MOD" : `@${escapeHtml(lastChatFrom || "unknown")}`) + : ""; + const lastChatLine = lastChat + ? `<div class="small muted postLastChat">Last chat: ${lastChatWho}${lastChatText ? ` — ${escapeHtml(lastChatText)}` : ""}</div>` + : ""; + const typersSet = typingUsersByPostId.get(p.id); + const typingUsers = typersSet ? Array.from(typersSet.values()).slice(0, 2) : []; + const typingMore = typersSet && typersSet.size > typingUsers.length ? ` +${typersSet.size - typingUsers.length}` : ""; + const typingLine = typingUsers.length + ? `<div class="small muted postTypingLine">${typingUsers.map((u) => `@${escapeHtml(u)}`).join(", ")}${typingMore} typing...</div>` + : ""; return ` <article class="post${unreadClass}${newClass}${buzzClass}" data-id="${p.id}" ${cardTint}> @@ -6102,6 +6344,8 @@ function renderFeed() { ${deletedLine} ${editedLine} ${contentBlock} + ${typingLine} + ${lastChatLine} <div class="postMeta">${collectionTag}${tags ? ` ${tags}` : ""}</div> ${reactionsHtml} </article>`; @@ -6506,6 +6750,9 @@ function renderPeoplePanel() { ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}" disabled>Open</button>` : `<span class="muted small">Blocked</span>`; } + if (canModerate && other) { + actions += ` <button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(other)}">Mod DM</button>`; + } return `<div class="dmThreadCard"> <div class="dmThreadTop"> @@ -6549,6 +6796,7 @@ function renderPeoplePanel() { const statusText = `${status}${m.online ? "" : ""}`; const cardStyle = peopleOnlineCardStyle(m); const canDm = Boolean(loggedInUser && username && String(username).toLowerCase() !== String(loggedInUser).toLowerCase()); + const canModDm = Boolean(canModerate && username && String(username).toLowerCase() !== String(loggedInUser || "").toLowerCase()); return `<div class="peopleCard" data-viewprofile="${escapeHtml(username)}" ${cardStyle}> <div class="peopleCardTop"> <div>${renderUserPill(username)} <span class="modStatus">${escapeHtml(role)}</span></div> @@ -6557,6 +6805,7 @@ function renderPeoplePanel() { <div class="peopleCardActions"> <button type="button" class="ghost smallBtn" data-viewprofile="${escapeHtml(username)}">Profile</button> <button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(username)}" ${canDm ? "" : "disabled"}>DM</button> + ${canModDm ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(username)}">Mod DM</button>` : ""} </div> </div>`; }) @@ -7518,7 +7767,12 @@ function renderChatPanel(forceScroll = false) { if (chatPanelEl) chatPanelEl.classList.remove("walkie"); if (walkieBarEl) walkieBarEl.classList.add("hidden"); if (chatForm) chatForm.classList.remove("hidden"); - chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div><div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div>`; + chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div> + <div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div> + <div class="row" style="gap:8px;justify-content:flex-start;margin-top:8px;"> + <button type="button" class="ghost smallBtn" data-chatemptyopen="hives">Open Hives</button> + <button type="button" class="ghost smallBtn" data-chatemptyopen="people">Open People</button> + </div>`; restoreMediaState(chatMessagesEl, mediaState); setReplyToMessage(null); return; @@ -7835,9 +8089,11 @@ function updateActiveChatMeta() { chatMeta.textContent = `${author} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); } -function openDmThread(threadId) { +function openDmThread(threadId, opts = null) { const id = String(threadId || "").trim(); if (!id) return; + const options = opts && typeof opts === "object" ? opts : {}; + if (!options.preserveFocus) blurFocusedChatComposer(); const thread = dmThreadsById.get(id) || null; if (!thread) { pendingOpenDmThreadId = id; @@ -7865,10 +8121,35 @@ function openDmThread(threadId) { } } +function sendModDmPrompt(rawUsername) { + const to = String(rawUsername || "") + .trim() + .replace(/^@+/, "") + .toLowerCase(); + if (!to) return; + if (!loggedInUser) { + toast("Sign in required", "Sign in to send moderator DMs."); + return; + } + if (!canModerate) { + toast("Moderator only", "You need moderator permissions."); + return; + } + if (to === String(loggedInUser).toLowerCase()) { + toast("Unavailable", "Can't send a moderator DM to yourself."); + return; + } + const text = String(prompt(`Send moderator DM to @${to}:`) || "").trim(); + if (!text) return; + ws.send(JSON.stringify({ type: "dmSendMod", to, text })); + toast("Moderator DM", `Sent to @${to}.`); +} + function openChat(postId, opts = null) { activeDmThreadId = null; stopWalkieRecording(); const options = opts && typeof opts === "object" ? opts : {}; + if (!options.preserveFocus) blurFocusedChatComposer(); const sourceEl = options.sourceEl instanceof HTMLElement ? options.sourceEl : null; const post = posts.get(postId); if (!post) return; @@ -8211,6 +8492,13 @@ function isTextEntryFocused() { return Boolean(el.isContentEditable); } +function shouldSubmitChatOnEnter(evt) { + if (!evt || evt.key !== "Enter") return false; + const mode = readChatEnterModePref(); + if (mode === "enter") return !(evt.shiftKey || evt.altKey || evt.ctrlKey || evt.metaKey); + return Boolean(evt.ctrlKey || evt.metaKey); +} + function cycleLayoutPresetBy(step) { if (!layoutPresetEl || !rackLayoutEnabled || layoutPresetEl.disabled) return; const options = Array.from(layoutPresetEl.options || []) @@ -8297,12 +8585,10 @@ function cycleChatContextBy(step) { return true; } if (next.startsWith("dm:")) { - openDmThread(next.slice(3)); - return true; + return openChatContextValue(next, { preserveFocus: false }); } if (next.startsWith("post:")) { - openChat(next.slice(5)); - return true; + return openChatContextValue(next, { preserveFocus: false }); } return false; } @@ -9156,6 +9442,11 @@ window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => { if (e.defaultPrevented) return; if (e.repeat) return; + if (e.key === "?" && !isTextEntryFocused()) { + e.preventDefault(); + setShortcutHelpOpen(true); + return; + } if (e.altKey || e.ctrlKey || e.metaKey) return; if (isTextEntryFocused()) return; const ctx = activePanelContextForHotkeys(); @@ -9201,6 +9492,26 @@ window.addEventListener("click", (e) => { }); chatMessagesEl.addEventListener("click", (e) => { + const emptyActionBtn = e.target.closest("button[data-chatemptyopen]"); + if (emptyActionBtn) { + const target = String(emptyActionBtn.getAttribute("data-chatemptyopen") || "").trim().toLowerCase(); + if (target === "hives") { + if (isMobileSwipeMode()) { + setMobilePanel("hives"); + } else { + const hivesHeader = hivesPanelEl?.querySelector?.(".panelHeader"); + hivesHeader?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); + } + return; + } + if (target === "people") { + const peopleEl = getPanelElement("people") || peopleDrawerEl; + if (peopleEl && typeof undockPanel === "function" && isDocked("people")) undockPanel("people"); + peopleEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); + return; + } + } + const mobileChatOpenBtn = e.target.closest("button[data-mobilechatopen]"); if (mobileChatOpenBtn) { const postId = mobileChatOpenBtn.getAttribute("data-mobilechatopen") || ""; @@ -9333,15 +9644,7 @@ chatContextSelectEl?.addEventListener("change", () => { if (syncingChatContextSelect) return; const raw = String(chatContextSelectEl.value || "").trim(); if (!raw) return; - if (raw.startsWith("dm:")) { - const id = raw.slice(3); - if (id) openDmThread(id); - return; - } - if (raw.startsWith("post:")) { - const id = raw.slice(5); - if (id) openChat(id); - } + openChatContextValue(raw, { preserveFocus: false }); }); modPanelEl?.addEventListener("click", (e) => { @@ -10088,7 +10391,7 @@ chatEditor.addEventListener("keydown", (e) => { } } if (e.key !== "Enter") return; - if (!(e.ctrlKey || e.metaKey)) return; + if (!shouldSubmitChatOnEnter(e)) return; e.preventDefault(); submitChat(); }); @@ -11130,6 +11433,20 @@ if (enableHintsEl) { writeHintsEnabledPref(Boolean(enableHintsEl.checked)); }); } +if (chatEnterModeEl) { + chatEnterModeEl.value = readChatEnterModePref(); + chatEnterModeEl.addEventListener("change", () => { + writeChatEnterModePref(chatEnterModeEl.value); + }); +} +if (resetCurrentLayoutBtn) { + resetCurrentLayoutBtn.addEventListener("click", () => { + if (!rackLayoutEnabled) return; + const currentPreset = String(rackLayoutState?.presetId || layoutPresetEl?.value || "defaultSocial"); + applyPreset(currentPreset); + toast("Layout", "Current preset layout reset."); + }); +} renderPeoplePanel(); setPeopleOpen(getPeopleOpen()); composerOpen = getComposerOpen(); @@ -11218,8 +11535,18 @@ mediaModalCopyLink?.addEventListener("click", async () => { if (mediaModalStatus) mediaModalStatus.textContent = "Copy failed (clipboard blocked)."; } }); +shortcutHelpModal?.addEventListener("click", (e) => { + if (e.target?.getAttribute?.("data-shortcutclose")) setShortcutHelpOpen(false); +}); +shortcutHelpCloseBtn?.addEventListener("click", () => setShortcutHelpOpen(false)); +openShortcutHelpBtn?.addEventListener("click", () => setShortcutHelpOpen(true)); document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && mediaModal && !mediaModal.classList.contains("hidden")) setMediaModalOpen(false); + if (e.key !== "Escape") return; + if (mediaModal && !mediaModal.classList.contains("hidden")) { + setMediaModalOpen(false); + return; + } + if (shortcutHelpModal && !shortcutHelpModal.classList.contains("hidden")) setShortcutHelpOpen(false); }); document.body.addEventListener("click", (e) => { const img = e.target?.closest?.("img"); @@ -11253,6 +11580,11 @@ peopleDmsTabBtn?.addEventListener("click", () => { }); peopleSearchEl?.addEventListener("input", () => renderPeoplePanel()); peopleListEl?.addEventListener("click", (e) => { + const modDmBtn = e.target.closest("button[data-moddm]"); + if (modDmBtn) { + sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); + return; + } const dmBtn = e.target.closest("button[data-dmrequest]"); if (dmBtn) { const to = String(dmBtn.getAttribute("data-dmrequest") || "") @@ -11277,6 +11609,11 @@ peopleListEl?.addEventListener("click", (e) => { }); peopleDmsViewEl?.addEventListener("click", (e) => { + const modDmBtn = e.target.closest("button[data-moddm]"); + if (modDmBtn) { + sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); + return; + } const profileLink = e.target.closest("[data-viewprofile]"); if (profileLink) { const username = profileLink.getAttribute("data-viewprofile") || ""; @@ -11374,6 +11711,11 @@ onboardingPanelBodyEl?.addEventListener("click", (e) => { }); profileCard?.addEventListener("click", (e) => { + const modDmBtn = e.target.closest("button[data-moddm]"); + if (modDmBtn) { + sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); + return; + } const dmBtn = e.target.closest("button[data-dmrequest]"); if (!dmBtn) return; const to = String(dmBtn.getAttribute("data-dmrequest") || "") diff --git a/public/index.html b/public/index.html @@ -4,7 +4,7 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Bzl - Hives</title> - <link rel="stylesheet" href="/styles.css?v=126" /> + <link rel="stylesheet" href="/styles.css?v=130" /> </head> <body> <div class="app"> @@ -28,7 +28,7 @@ <section id="viewPanel" class="panel"> <div class="panelTitle">View</div> - <div class="uiHint">Use layout presets for quick panel setups. Shortcuts: <b>[</b>/<b>]</b> cycle presets, <b>-</b>/<b>=</b> cycle hives/chats in the active panel.</div> + <div class="uiHint">Use layout presets for quick panel setups. Shortcuts: <b>[</b>/<b>]</b> cycle presets, <b>-</b>/<b>=</b> cycle hives/chats in the active panel, <b>?</b> opens shortcut help.</div> <label class="checkRow" style="margin-top:8px;"> <span>Rack layout (experimental)</span> <input id="toggleRackLayout" type="checkbox" /> @@ -66,6 +66,17 @@ <span>Enable hints</span> <input id="enableHints" type="checkbox" /> </label> + <label style="margin-top:10px;"> + <span>Chat send key</span> + <select id="chatEnterMode"> + <option value="ctrlEnter">Ctrl/Cmd + Enter sends</option> + <option value="enter">Enter sends (Shift+Enter newline)</option> + </select> + </label> + <div class="row" style="margin-top:8px; gap:8px; flex-wrap:wrap;"> + <button id="openShortcutHelp" class="ghost smallBtn" type="button">Shortcut help</button> + <button id="resetCurrentLayout" class="ghost smallBtn" type="button">Reset layout</button> + </div> <details style="margin-top:10px;"> <summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary> @@ -196,7 +207,7 @@ <button id="toggleComposer" class="mobileComposerToggle" type="button">New Hive</button> </div> </div> - <div class="uiHint">Use filters to narrow posts, then tap <b>Chat</b> on a hive card. Shortcut in Hives: <b>-</b>/<b>=</b> cycles collections/views.</div> + <div class="uiHint">Use filters to narrow posts, then tap <b>Chat</b> on a hive card. Cards now show latest chat/typing. Shortcut: <b>-</b>/<b>=</b> cycles collections/views.</div> <div class="hiveTabs" id="hiveTabs"> <button type="button" data-hiveview="all" class="primary">All</button> <button type="button" data-hiveview="starred" class="ghost">Starred</button> @@ -451,14 +462,14 @@ <button id="peopleDmsTab" class="ghost" type="button">DMs</button> </div> <div id="peopleMembersView"> - <div class="uiHint">Members list lets you open profiles and start DMs. Search filters by username.</div> + <div class="uiHint">Members list lets you open profiles and start DMs. Mods can also send <b>Mod DM</b> from member/profile actions.</div> <div class="peopleFilters"> <input id="peopleSearch" placeholder="Search members" /> </div> <div id="peopleList" class="peopleList small"></div> </div> <div id="peopleDmsView" class="peopleDms small hidden"> - <div class="uiHint">DM requests must be accepted before chat opens. Active threads show an <b>Open</b> button.</div> + <div class="uiHint">DM requests must be accepted before chat opens. Active threads show <b>Open</b>, and mods get a <b>Mod DM</b> action.</div> DMs coming soon. </div> </aside> @@ -599,7 +610,26 @@ </div> </div> + <div id="shortcutHelpModal" class="modal hidden" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts"> + <div class="modalBackdrop" data-shortcutclose="1"></div> + <div class="modalCard panel"> + <div class="panelHeader"> + <div class="panelTitle">Keyboard Shortcuts</div> + <div class="row"> + <button id="shortcutHelpClose" class="ghost smallBtn" type="button">Close</button> + </div> + </div> + <div class="modalBody shortcutHelpBody"> + <div><span class="tag">[ / ]</span> Cycle layout presets</div> + <div><span class="tag">- / =</span> Cycle hives or chats in active panel</div> + <div><span class="tag">?</span> Open this shortcut help</div> + <div><span class="tag">`</span> Hold for walkie talkie (when enabled)</div> + <div><span class="tag">Esc</span> Close menus/modals</div> + </div> + </div> + </div> + <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> - <script src="/app.js?v=146"></script> + <script src="/app.js?v=152"></script> </body> </html> diff --git a/public/styles.css b/public/styles.css @@ -773,6 +773,35 @@ body { overflow: hidden; } +.workspaceSlot.workspaceSlotEmpty { + border: 1px dashed color-mix(in srgb, var(--text) 16%, transparent); + border-radius: 14px; + background: color-mix(in srgb, var(--panel) 88%, transparent); + align-items: center; + justify-content: center; +} + +.workspaceEmptyAdd { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + padding: 10px 14px; + border: 1px dashed color-mix(in srgb, var(--accent) 30%, transparent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.workspaceEmptyAddPlus { + width: 20px; + height: 20px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--accent) 24%, transparent); + font-weight: 900; +} + .workspaceSlot > .rackPanel { flex: 1; min-height: 0; @@ -1086,6 +1115,12 @@ body { justify-content: flex-end; } +.shortcutHelpBody { + display: flex; + flex-direction: column; + gap: 10px; +} + .editMetaGrid { display: grid; grid-template-columns: 1fr 1fr; @@ -2207,6 +2242,11 @@ button:disabled { margin-top: 10px; } +.postLastChat, +.postTypingLine { + margin-top: 6px; +} + .reactionsRow { display: flex; gap: 6px; @@ -2777,10 +2817,91 @@ button:disabled { border-top: 1px solid var(--line); } +.app.hotbarVisible .chatForm { + padding-bottom: 76px; +} + .chatForm > .primary[type="submit"] { align-self: flex-end; } +.chat.isSkinnyChat .chatMessages { + padding: 8px; +} + +.chat.isSkinnyChat .chatMsg { + width: auto; + max-width: 100%; + margin-left: 0; + margin-right: 0; + border-radius: 10px; + padding: 6px 8px; +} + +.chat.isSkinnyChat .chatMsg.railLeft, +.chat.isSkinnyChat .chatMsg.railCenter, +.chat.isSkinnyChat .chatMsg.railRight { + margin-left: 0; + margin-right: 0; + max-width: 100%; +} + +.chat.isSkinnyChat .chatMsg .meta { + font-size: 10px; + margin-bottom: 2px; +} + +.chat.isSkinnyChat .chatMsg .content { + font-size: 12px; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat.isSkinnyChat .chatMsg .content img, +.chat.isSkinnyChat .chatMsg .content audio, +.chat.isSkinnyChat .chatMsg .content video, +.chat.isSkinnyChat .chatReplyRef, +.chat.isSkinnyChat .chatReactions { + display: none !important; +} + +.chat.isSkinnyChat .chatActionsRow { + margin-top: 4px; + gap: 6px; +} + +.chat.isSkinnyChat .chatTools { + margin-top: 0; + gap: 4px; + justify-content: flex-start; +} + +.chat.isSkinnyChat .chatTools .smallBtn { + padding: 3px 8px; +} + +.chat.isSkinnyChat .toolbar { + padding: 6px; + gap: 4px; +} + +.chat.isSkinnyChat .toolbar .sep, +.chat.isSkinnyChat .toolbar button[data-chatcmd], +.chat.isSkinnyChat .toolbar button[data-chatemoji] { + display: none; +} + +.chat.isSkinnyChat .chatEditor { + min-height: 64px; +} + +.chat.isSkinnyChat .chatForm { + gap: 8px; + padding: 8px; +} + .chatReplyBanner { border: 1px solid rgba(246, 240, 255, 0.16); border-radius: 12px;