// 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.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"; for (const reaction of reactions) { 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.buildFeedAttachmentBlock = entry => { const attachments = Array.isArray(entry.attachments) ? entry.attachments : entry.attachment ? [entry.attachment] : []; if (!attachments.length) return null; const wrap = document.createElement("div"); wrap.className = "message-attachments-list"; for (const attachment of attachments) { if (String(attachment.mime_type || "").startsWith("image/")) { const imageButton = document.createElement("button"); imageButton.className = "message-image-link"; imageButton.type = "button"; const image = document.createElement("img"); image.className = "message-image-preview"; image.src = attachment.url; image.alt = attachment.original_name || "Image attachment"; image.loading = "lazy"; imageButton.append(image); imageButton.addEventListener("click", () => { Glow.openMediaModal?.({ url: attachment.url, name: attachment.original_name, mimeType: attachment.mime_type }); }); wrap.append(imageButton); } else if (String(attachment.mime_type || "").startsWith("audio/")) { const audio = document.createElement("audio"); audio.controls = true; audio.preload = "metadata"; audio.src = attachment.url; wrap.append(audio); } else if (String(attachment.mime_type || "").startsWith("video/")) { const video = document.createElement("video"); video.controls = true; video.preload = "metadata"; video.src = attachment.url; video.className = "message-video-preview"; wrap.append(video); } const link = document.createElement("a"); link.className = "message-attachment"; link.href = attachment.url; link.target = "_blank"; link.rel = "noopener noreferrer"; link.download = attachment.original_name; link.textContent = `Attachment: ${attachment.original_name}`; wrap.append(link); } return wrap; }; Glow.buildFeedEntry = entry => { const card = document.createElement("article"); card.className = "feed-entry"; if (entry.is_mention) { card.classList.add("mention"); } card.dataset.messageId = String(entry.id); if (entry.room_slug) card.dataset.roomSlug = entry.room_slug; if (entry.team_slug) card.dataset.teamSlug = entry.team_slug; if (entry.channel_slug) card.dataset.channelSlug = entry.channel_slug; const heading = document.createElement("div"); heading.className = "feed-entry-heading"; const room = document.createElement("button"); room.className = "feed-room-link"; room.type = "button"; if (entry.team_name && entry.channel_name) { room.textContent = `${entry.team_name} ยท # ${entry.channel_name}`; } else { room.textContent = `${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`; } room.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(); heading.append(room, 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"; body.textContent = entry.text; const attachmentBlock = Glow.buildFeedAttachmentBlock(entry); const reactions = Glow.buildFeedReactions(entry); const openButton = document.createElement("button"); openButton.className = "small-button feed-open-room"; openButton.type = "button"; openButton.textContent = "Open channel"; openButton.addEventListener("click", () => { Glow.openFeedEntry(entry); }); card.addEventListener("dblclick", () => { Glow.openFeedEntry(entry); }); card.append(heading, author, body); if (attachmentBlock) { card.append(attachmentBlock); } if (reactions) { card.append(reactions); } card.append(openButton); return card; }; 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(); };