glo/static/js/05-feed.js
2026-10-31 08:52:14 +00:00

412 lines
11 KiB
JavaScript

// 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();
};