glo/static/js/05-feed.js
2026-09-24 22:33:36 +00:00

431 lines
12 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.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();
};