feature: watches on the backend, need frontend implementation for

notifications and unread indicators
This commit is contained in:
Andy Burke 2025-10-25 14:57:28 -07:00
parent 7046bb0389
commit 6293374bb7
28 changed files with 1405 additions and 608 deletions

275
public/js/app.js Normal file
View file

@ -0,0 +1,275 @@
const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)?/gm;
const UPDATE_TOPICS_FREQUENCY = 60_000;
const APP = {
user_servers: [],
user_watches: [],
_event_callbacks: {},
on: function( event_name, callback ) {
this._event_callbacks[ event_name ] = this._event_callbacks[ event_name ] ?? new Set();
this._event_callbacks[event_name ].add( callback );
return true;
},
off: function( event_name, callback ) {
return this._event_callbacks[ event_name ]?.delete( callback );
},
_emit: function( event_name, event_data ) {
const event_callbacks = this._event_callbacks[ event_name ];
event_callbacks?.forEach( ( callback ) => {
callback( event_data );
});
},
check_if_logged_in: async function () {
try {
const session_response = await api.fetch("/api/users/me");
if (!session_response.ok) {
const error_body = await session_response.json();
const error = error_body?.error;
console.dir({
error_body,
error,
});
return;
}
const user = await session_response.json();
this.login( user );
} catch (error) {
console.dir({
error,
});
}
},
extract_url_hash_info: async function () {
HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls
const {
groups: { topic_id, view },
} = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? {
groups: {},
};
console.dir({
url: window.location.href,
hash: window.location.hash,
topic_id,
view,
});
if (!document.body.dataset.topic || document.body.dataset.topic !== topic_id) {
const previous = document.body.dataset.topic;
console.dir({
topic_changed: {
detail: {
previous,
topic_id,
},
},
});
document.body.dataset.topic = topic_id;
this._emit( 'topic_changed', {
previous,
topic_id
});
if (!topic_id) {
const first_topic_id = this.TOPICS.TOPIC_LIST[0]?.id;
if (first_topic_id) {
window.location.hash = `/topic/${first_topic_id}/chat`; // TODO: allow a different default than chat
}
}
}
if (!document.body.dataset.view || document.body.dataset.view !== view) {
const previous = document.body.dataset.view;
document.body.dataset.view = view;
console.dir({
view_changed: {
detail: {
previous,
view,
},
},
});
this._emit( 'view_changed', {
previous,
view
});
}
},
load: async function() {
this.server = {};
this.suggested_servers = [];
try {
const server_info_response = await api.fetch( '/files/settings/settings.json' );
if ( !server_info_response.ok ) {
throw new Error( 'Could not get server info.' );
}
const this_server_info = await server_info_response.json();
this.server = {
name: this_server_info?.name ?? document.title,
url: this_server_info?.url ?? window.location.origin ?? window.location.href,
icon: this_server_info?.icon ?? '/icons/favicon-128x128.png',
icon_background: this_server_info?.icon_background ?? undefined
};
const suggested_servers = await (await api.fetch( '/files/settings/suggested_servers.json' )).json();
}
catch( error ) {
console.error( error );
}
window.addEventListener("locationchange", this.extract_url_hash_info.bind( this ));
window.addEventListener("locationchange", this.TOPICS.update );
this.check_if_logged_in();
this.extract_url_hash_info();
this._emit( 'load', this );
},
update_user: async function( updated_user ) {
const user = this.user = updated_user;
document.body.dataset.user = JSON.stringify(user);
document.body.dataset.perms = user.permissions.join(":");
this.TOPICS.update();
this.user_servers = [];
try {
const user_server_response = await api.fetch( `/files/users/${ user.id }/settings/servers.json` );
this.user_servers = user_server_response.ok ? await user_server_response.json() : [];
}
catch( error ) {
console.error( error );
}
this.user_watches = [];
try {
const user_watches_response = await api.fetch( `/api/users/${ user.id }/watches` );
this.user_watches = user_watches_response.ok ? await user_watches_response.json() : [];
}
catch( error ) {
console.error( error );
}
// TODO: show unread indicators based on watches
},
login: async function( user ) {
await this.update_user( user );
this._emit( 'user_logged_in', { user } );
},
logout: function() {
delete document.body.dataset.user;
delete document.body.dataset.perms;
window.location = "/";
this._emit( "user_logged_out", {});
},
USERS: {
_evict_timeouts: {},
_update_timeouts: {},
get: async (id, force) => {
if (force || !APP.USERS[id]) {
APP.USERS[id] = await (await api.fetch(`/api/users/${id}`)).json();
}
if (!APP.USERS._update_timeouts[id]) {
APP.USERS._update_timeouts[id] = setInterval(() => {
APP.USERS.get(id, true);
}, 1 * 60_000);
}
if (!force) {
if (APP.USERS._evict_timeouts[id]) {
clearTimeout(APP.USERS._evict_timeouts[id]);
}
APP.USERS._evict_timeouts[id] = setTimeout(() => {
if (APP.USERS._update_timeouts[id]) {
clearTimeout(APP.USERS._update_timeouts[id]);
delete APP.USERS._update_timeouts[id];
}
delete APP.USERS[id];
}, 10 * 60_000);
}
return APP.USERS[id];
},
},
TOPICS: {
_last_topic_update: undefined,
_update_topics_timeout: undefined,
TOPIC_LIST: [],
update: async () => {
const now = new Date();
const time_since_last_update = now - (APP.TOPICS._last_topic_update ?? 0);
if (time_since_last_update < UPDATE_TOPICS_FREQUENCY / 2) {
return;
}
if (APP.TOPICS._update_topics_timeout) {
clearTimeout(APP.TOPICS._update_topics_timeout);
APP.TOPICS._update_topics_timeout = undefined;
}
try {
const topics_response = await api.fetch("/api/topics");
if (topics_response.ok) {
const new_topics = await topics_response.json();
const has_differences =
APP.TOPICS.TOPIC_LIST.length !== new_topics.length ||
new_topics.some((topic, index) => {
return (
APP.TOPICS.TOPIC_LIST[index]?.id !== topic.id ||
APP.TOPICS.TOPIC_LIST[index]?.name !== topic.name
);
});
if (has_differences) {
APP.TOPICS.TOPIC_LIST = [...new_topics];
APP._emit( 'topics_updated', {
topics: APP.TOPICS.TOPIC_LIST
});
}
APP.TOPICS._last_topic_update = now;
}
} catch (error) {
console.error(error);
}
APP.TOPICS._update_topics_timeout = setTimeout(
APP.TOPICS.update,
UPDATE_TOPICS_FREQUENCY,
);
// now that we have topics, make sure our url is all good
APP.extract_url_hash_info();
},
},
};
document.addEventListener("DOMContentLoaded", APP.load.bind( APP ));

View file

@ -11,6 +11,15 @@ const event_actions_popup_styling = `
overflow: hidden;
border: 1px solid var(--border-normal);
padding: 0.5rem;
visibility: hidden;
display: none;
opacity: 0;
}
#eventactionspopup[data-shown] {
visibility: visible;
display: block;
opacity: 1;
}
#eventactionspopup .icon.close {
@ -61,9 +70,7 @@ function open_event_actions_popup(event) {
event_actions_popup.style.left = position.x + "px";
event_actions_popup.style.top = position.y + "px";
event_actions_popup.style.visibility = "visible";
event_actions_popup.style.opacity = "1";
event_actions_popup.style.display = "block";
event_actions_popup.dataset.shown = true;
}
function clear_event_actions_popup() {
@ -71,9 +78,7 @@ function clear_event_actions_popup() {
return;
}
event_actions_popup.style.visibility = "hidden";
event_actions_popup.style.opacity = "0";
event_actions_popup.style.display = "none";
delete event_actions_popup.dataset.shown;
}
document.addEventListener("DOMContentLoaded", () => {

View file

@ -12,6 +12,15 @@ const reactions_popup_styling = `
border: 1px solid var(--border-normal);
padding: 0.5rem;
text-align: center;
visibility: hidden;
display: none;
opacity: 0;
}
#reactionspopup[data-shown] {
visibility: visible;
display: block;
opacity: 1;
}
#reactionspopup .icon.close {
@ -103,10 +112,7 @@ function open_reactions_popup(event) {
reactions_popup.style.left = position.x + "px";
reactions_popup.style.top = position.y + "px";
reactions_popup.style.visibility = "visible";
reactions_popup.style.opacity = "1";
reactions_popup.style.display = "block";
reactions_popup.dataset.shown = true;
reactions_popup_search_input.focus();
}
@ -115,9 +121,7 @@ function clear_reactions_popup() {
return;
}
reactions_popup.style.visibility = "hidden";
reactions_popup.style.opacity = "0";
reactions_popup.style.display = "none";
delete reactions_popup.dataset.shown;
}
document.addEventListener("DOMContentLoaded", () => {
@ -160,7 +164,7 @@ document.addEventListener("DOMContentLoaded", () => {
<input
type="hidden"
name="creator_id"
generator="() => { return JSON.parse( document.body.dataset.user ?? '{}' ).id; }"
generator="() => { return APP.user?.id; }"
/>
<input
@ -194,7 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
document.body.appendChild(reactions_popup);
reactions_popup_form = document.getElementById("reactions-selection-form");
document.addEventListener("topic_changed", ({ detail: { topic_id } }) => {
APP.on("topic_changed", ({ topic_id }) => {
const reaction_topic_id = topic_id ?? document.body.dataset.topic;
reactions_popup_form.action = reaction_topic_id
? `/api/topics/${reaction_topic_id}/events`

View file

@ -228,8 +228,12 @@ function smarten_feeds() {
return;
}
if ( error.name === 'TypeError' && error.message === 'NetworkError when attempting to fetch resource.' ) {
console.log( error.message );
return;
}
feed.dataset.error = JSON.stringify(error);
console.trace(error);
})
.finally(() => {
if (feed.__started && feed.dataset.longpolling) {

View file

@ -41,9 +41,7 @@ function smarten_forms() {
form.uploaded = [];
form.errors = [];
const user = document.body.dataset.user
? JSON.parse(document.body.dataset.user)
: undefined;
const user = APP.user;
if (!user) {
throw new Error("You must be logged in to upload files here.");
}