412 lines
11 KiB
JavaScript
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();
|
|
}; |