// static/js/05-feed.js "use strict"; window.Glow = window.Glow || {}; Glow.clearFeed = () => { Glow.state.feedLastSeenId = 0; Glow.state.feedKnownMessageIds.clear(); Glow.state.feedEntries = []; Glow.els.feedBox.replaceChildren(); }; Glow.renderFeedEmptyState = message => { if (Glow.els.feedBox.children.length) return; const state = document.createElement("div"); state.className = "empty-state"; const card = document.createElement("div"); card.className = "empty-card"; card.textContent = message; state.append(card); Glow.els.feedBox.append(state); }; Glow.openFeedRoom = async slug => { const room = Glow.state.rooms.find(item => item.slug === slug); if (!room) { Glow.showToast("This room is no longer available.", "error"); return; } Glow.state.activeDm = null; Glow.state.activeChannel = null; Glow.state.activeRoom = room; Glow.state.activeView = "room"; Glow.els.roomSelect.value = room.slug; Glow.updateViewUI(); await Glow.loadMessages(); Glow.pingPresence?.(); Glow.refreshPresence?.(); Glow.schedulePoll(); }; Glow.openFeedEntry = async entry => { if ( entry.team_slug && entry.channel_slug && typeof Glow.loadChannelsForTeam === "function" ) { let team = Glow.state.teams?.find(item => item.slug === entry.team_slug); if (!team && typeof Glow.loadTeamsForAdapter === "function") { try { await Glow.loadTeamsForAdapter(); team = Glow.state.teams?.find(item => item.slug === entry.team_slug); } catch (_) { team = null; } } if (team) { Glow.state.activeTeam = team; Glow.state.activeDm = null; if (Glow.els.teamSelect) { Glow.els.teamSelect.value = team.slug; } try { await Glow.loadChannelsForTeam(team.slug, entry.channel_slug); if (Glow.state.activeChannel) { Glow.state.activeView = "channel"; Glow.updateViewUI(); await Glow.loadChannelMessages?.(); Glow.pollTyping?.(); Glow.pingPresence?.(); Glow.refreshPresence?.(); Glow.schedulePoll(); return; } } catch (error) { Glow.showToast(error.message || "Could not open this channel.", "error"); return; } } } if (entry.room_slug) { await Glow.openFeedRoom(entry.room_slug); return; } Glow.showToast("This conversation is no longer available.", "error"); }; Glow.feedAttachmentSummary = entry => { const attachments = Array.isArray(entry.attachments) ? entry.attachments : entry.attachment ? [entry.attachment] : []; if (!attachments.length) return ""; if (attachments.length > 1) { return `${attachments.length} attachments`; } const item = attachments[0]; const mime = String(item?.mime_type || "").toLowerCase(); if (mime.startsWith("image/")) return "image attachment"; if (mime.startsWith("audio/")) return "audio attachment"; if (mime.startsWith("video/")) return "video attachment"; return "file attachment"; }; Glow.buildFeedReactions = entry => { const reactions = Array.isArray(entry.reactions) ? entry.reactions : []; if (!reactions.length) return null; const row = document.createElement("div"); row.className = "feed-entry-reactions compact"; for (const reaction of reactions.slice(0, 6)) { const chip = document.createElement("button"); chip.className = "reaction-chip"; chip.type = "button"; chip.textContent = `${reaction.emoji} ${reaction.count}`; if (reaction.reacted) { chip.classList.add("active"); } chip.addEventListener("click", async () => { try { if (reaction.reacted) { await Glow.apiRemoveReaction(entry.id, reaction.emoji); } else { await Glow.apiAddReaction(entry.id, reaction.emoji); } await Glow.loadFeed(); } catch (error) { Glow.showToast(error.message || "Could not update reaction.", "error"); } }); row.append(chip); } return row; }; Glow.buildFeedEntry = entry => { const row = document.createElement("article"); row.className = "feed-entry timeline-row"; if (entry.is_mention) { row.classList.add("mention"); } row.dataset.messageId = String(entry.id); if (entry.room_slug) row.dataset.roomSlug = entry.room_slug; if (entry.team_slug) row.dataset.teamSlug = entry.team_slug; if (entry.channel_slug) row.dataset.channelSlug = entry.channel_slug; const top = document.createElement("div"); top.className = "feed-entry-top"; const context = document.createElement("button"); context.className = "feed-room-link"; context.type = "button"; if (entry.team_name && entry.channel_name) { context.textContent = `${entry.team_name} · # ${entry.channel_name}`; } else { context.textContent = `${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`; } context.addEventListener("click", () => { Glow.openFeedEntry(entry); }); const time = document.createElement("time"); time.className = "feed-entry-time"; time.dateTime = entry.created_at; time.textContent = Glow.relativeTime(entry.created_at); time.title = new Date(entry.created_at).toLocaleString(); top.append(context, time); const author = document.createElement("button"); author.className = "feed-entry-author profile-link"; author.type = "button"; author.textContent = entry.username; Glow.applyUserColor(author, entry); author.addEventListener("click", () => { Glow.openProfile?.(entry.username); }); const body = document.createElement("div"); body.className = "feed-entry-body clamp-2"; body.textContent = entry.text || ""; const footer = document.createElement("div"); footer.className = "feed-entry-footer"; const meta = document.createElement("div"); meta.className = "feed-entry-meta"; const attachmentSummary = Glow.feedAttachmentSummary(entry); if (attachmentSummary) { const attachmentButton = document.createElement("button"); attachmentButton.className = "small-button feed-attachment-summary"; attachmentButton.type = "button"; attachmentButton.textContent = attachmentSummary; attachmentButton.addEventListener("click", () => { Glow.openFeedEntry(entry); }); meta.append(attachmentButton); } const reactions = Glow.buildFeedReactions(entry); if (reactions) { meta.append(reactions); } const openButton = document.createElement("button"); openButton.className = "small-button feed-open-room"; openButton.type = "button"; openButton.textContent = "Open"; openButton.addEventListener("click", () => { Glow.openFeedEntry(entry); }); footer.append(meta, openButton); row.addEventListener("dblclick", () => { Glow.openFeedEntry(entry); }); row.addEventListener("click", event => { if ( event.target instanceof HTMLElement && (event.target.closest("button") || event.target.closest("a")) ) { return; } Glow.openFeedEntry(entry); }); row.append(top, author, body, footer); return row; }; Glow.appendFeedEntries = (entries, forceScroll = false) => { if (!Array.isArray(entries) || !entries.length) { if (!Glow.state.feedKnownMessageIds.size) { Glow.renderFeedEmptyState( Glow.state.currentUser ? "No activity in your accessible rooms yet." : "Sign in or create an account to see your personalized activity feed." ); } return; } const nearBottom = Glow.els.feedBox.scrollHeight - Glow.els.feedBox.scrollTop - Glow.els.feedBox.clientHeight < 100; const fragment = document.createDocumentFragment(); for (const entry of entries) { const id = Number(entry.id); if (!id || Glow.state.feedKnownMessageIds.has(id)) { continue; } const previous = Glow.state.feedEntries.at(-1); const dateChanged = !previous || Glow.dateKey(previous.created_at) !== Glow.dateKey(entry.created_at); if (dateChanged) { const separator = document.createElement("div"); separator.className = "date-separator"; separator.dataset.dateKey = Glow.dateKey(entry.created_at); const label = document.createElement("span"); label.textContent = Glow.formatDateLabel(entry.created_at); separator.append(label); fragment.append(separator); } Glow.state.feedKnownMessageIds.add(id); Glow.state.feedLastSeenId = Math.max(Glow.state.feedLastSeenId, id); Glow.state.feedEntries.push(entry); fragment.append(Glow.buildFeedEntry(entry)); } if (!fragment.childNodes.length) return; Glow.els.feedBox.querySelector(".empty-state")?.remove(); Glow.els.feedBox.append(fragment); if (forceScroll || nearBottom) { Glow.els.feedBox.scrollTop = Glow.els.feedBox.scrollHeight; } }; Glow.loadFeed = async () => { const requestId = ++Glow.state.feedRequestId; Glow.els.feedBox.setAttribute("aria-busy", "true"); try { const data = await Glow.apiRequest("/api/feed"); if (requestId !== Glow.state.feedRequestId || Glow.state.activeView !== "feed") { return; } Glow.clearFeed(); Glow.appendFeedEntries(data.entries || [], true); Glow.state.feedLastSeenId = Number(data.latest_id) || 0; if (!(data.entries || []).length) { Glow.renderFeedEmptyState( Glow.state.currentUser ? "No activity in your accessible rooms yet." : "Sign in or create an account to see your personalized activity feed." ); } } catch (error) { if (requestId !== Glow.state.feedRequestId) { return; } Glow.clearFeed(); Glow.renderFeedEmptyState(error.message || "Could not load activity feed."); Glow.showToast(error.message || "Could not load activity feed.", "error"); } finally { if (requestId === Glow.state.feedRequestId) { Glow.els.feedBox.setAttribute("aria-busy", "false"); } } }; Glow.pollFeed = async () => { if ( Glow.state.feedPollInFlight || Glow.state.activeView !== "feed" || document.hidden ) { return; } Glow.state.feedPollInFlight = true; try { const data = await Glow.apiRequest( `/api/feed?after_id=${encodeURIComponent(Glow.state.feedLastSeenId)}` ); if (Glow.state.activeView !== "feed") { return; } Glow.appendFeedEntries(data.entries || [], false); Glow.state.feedLastSeenId = Math.max( Glow.state.feedLastSeenId, Number(data.latest_id) || 0 ); } catch (error) { if (error.status === 404) { console.warn("Feed API is unavailable. Deploy the /api/feed backend route."); return; } if ( error.status !== 401 && error.code !== "blacklisted" && error.code !== "network" ) { console.error("Feed polling failed:", error); } } finally { Glow.state.feedPollInFlight = false; } }; Glow.openFeed = async () => { Glow.state.activeDm = null; Glow.state.activeView = "feed"; Glow.updateViewUI(); await Glow.loadFeed(); Glow.schedulePoll(); };