refactor: clean up chat and split up embed handling

This commit is contained in:
Andy Burke 2025-09-16 12:25:11 -07:00
parent 03751c6d00
commit 7e4ab72fe6
14 changed files with 352 additions and 274 deletions

View file

@ -16,8 +16,9 @@ const DEFAULT_USER_PERMISSIONS: string[] = [
'topics.read', 'topics.read',
'topics.chat.write', 'topics.chat.write',
'topics.chat.read', 'topics.chat.read',
'topics.threads.write', 'topics.posts.create',
'topics.threads.read', 'topics.posts.write',
'topics.posts.read',
'users.read' 'users.read'
]; ];

View file

@ -254,9 +254,9 @@ body[data-perms*="topics.write"] [data-requires-permission="topics.write"],
body[data-perms*="topics.chat.create"] [data-requires-permission="topics.chat.create"], body[data-perms*="topics.chat.create"] [data-requires-permission="topics.chat.create"],
body[data-perms*="topics.chat.read"] [data-requires-permission="topics.chat.read"], body[data-perms*="topics.chat.read"] [data-requires-permission="topics.chat.read"],
body[data-perms*="topics.chat.write"] [data-requires-permission="topics.chat.write"], body[data-perms*="topics.chat.write"] [data-requires-permission="topics.chat.write"],
body[data-perms*="topics.threads.create"] [data-requires-permission="topics.threads.create"], body[data-perms*="topics.posts.create"] [data-requires-permission="topics.posts.create"],
body[data-perms*="topics.threads.read"] [data-requires-permission="topics.threads.read"], body[data-perms*="topics.posts.read"] [data-requires-permission="topics.posts.read"],
body[data-perms*="topics.threads.write"] [data-requires-permission="topics.threads.write"], body[data-perms*="topics.posts.write"] [data-requires-permission="topics.posts.write"],
body[data-perms*="users.read"] [data-requires-permission="users.read"], body[data-perms*="users.read"] [data-requires-permission="users.read"],
body[data-perms*="users.write"] [data-requires-permission="users.write"], body[data-perms*="users.write"] [data-requires-permission="users.write"],
body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"] { body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"] {

View file

@ -13,6 +13,19 @@
<script src="./js/audioplayer.js" type="text/javascript"></script> <script src="./js/audioplayer.js" type="text/javascript"></script>
<script src="./js/datetimeutils.js" type="text/javascript"></script> <script src="./js/datetimeutils.js" type="text/javascript"></script>
<script src="./js/embeds/audio.js" type="text/javascript"></script>
<script src="./js/embeds/gif.js" type="text/javascript"></script>
<script src="./js/embeds/image.js" type="text/javascript"></script>
<script src="./js/embeds/link.js" type="text/javascript"></script>
<script src="./js/embeds/mp4.js" type="text/javascript"></script>
<script src="./js/embeds/spotify.js" type="text/javascript"></script>
<script src="./js/embeds/tidal.js" type="text/javascript"></script>
<script src="./js/embeds/vimeo.js" type="text/javascript"></script>
<script src="./js/embeds/youtube.js" type="text/javascript"></script>
<script src="./js/htmlify.js" type="text/javascript"></script>
<script src="./js/locationchange.js" type="text/javascript"></script> <script src="./js/locationchange.js" type="text/javascript"></script>
<script src="./js/notifications.js" type="text/javascript"></script> <script src="./js/notifications.js" type="text/javascript"></script>
<script src="./js/totp.js" type="text/javascript"></script> <script src="./js/totp.js" type="text/javascript"></script>
@ -29,6 +42,40 @@
</main> </main>
</body> </body>
<script> <script>
/* globals - sue me */
const USERS = {
_evict_timeouts: {},
_update_timeouts: {},
get: async (id, force) => {
if ( force || !USERS[ id ] ) {
USERS[ id ] = (await (await api.fetch(`/api/users/${id}`)).json());
}
if ( !USERS._update_timeouts[ id ] ) {
USERS._update_timeouts[ id ] = setInterval( () => {
USERS.get( id, true );
}, 1 * 60_000 );
}
if ( !force ) {
if ( USERS._evict_timeouts[ id ] ) {
clearTimeout( USERS._evict_timeouts[ id ] );
}
USERS._evict_timeouts[id] = setTimeout( () => {
if ( USERS._update_timeouts[ id ] ) {
clearTimeout( USERS._update_timeouts[ id ] );
delete USERS._update_timeouts[ id ];
}
delete USERS[ id ];
}, 10 * 60_000 );
}
return USERS[ id ];
}
};
const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)/gm; const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)/gm;
function extract_url_hash_info() { function extract_url_hash_info() {

48
public/js/embeds/audio.js Normal file
View file

@ -0,0 +1,48 @@
function embed_audio(link_info) {
if (typeof link_info.extension !== "string") {
return;
}
const mime_types = get_mime_types(link_info.extension);
const is_audio = mime_types[0]?.indexOf("audio") === 0;
if (!is_audio) {
return;
}
return `
<div class="audio-container" tabindex="-1">
<audio controls>
<source src="${link_info.url}" type="${mime_types[0].indexOf("audio/mpeg") === 0 ? "audio/mpeg" : mime_types[0]}">
Your browser does not support the audio element.
</audio>
<div class="enhanced-audio-player-container">
<div class="audio-player-display-container">
<canvas class="audio-player-display"></canvas>
</div>
<div class="audio-controls-container">
<div class="progress-container">
<div class="time-container"><span class="current">00:00</span></div>
<div class="slider-container">
<input type="range" name="progress" title="" min="0" max="1000" step="1" value="0" />
<label class="time-container" for="progress"><span class="current">00:00</span></label>
</div>
<div class="time-container"><span class="duration">00:00</span></div>
</div>
<div class="buttons-container">
<div class="audio-control blank">
<a href="${link_info.url}" download>
<div class="icon download"></div>
</a>
</div>
<div class="audio-control skip-back"><div class="icon skip-back"></div></div>
<div class="audio-control play-pause-toggle"><div class="icon play"></div><div class="icon pause"></div></div>
<div class="audio-control skip-forward"><div class="icon skip-forward"></div></div>
<div class="audio-control volume">
<input type="range" name="volume" title="Volume" min="0" max="100" step="1" value="" />
</div>
</div>
</div>
</div>
</div>`;
}

13
public/js/embeds/gif.js Normal file
View file

@ -0,0 +1,13 @@
function embed_gif(link_info) {
if (typeof link_info.extension !== "string") {
return;
}
const is_gif = get_mime_types(link_info.extension).includes("image/gif");
if (!is_gif) {
return;
}
return `<div class="embed-container image gif"><img src="${link_info.url}" alt="A gif from ${link_info.domain}" /></div>`;
}

13
public/js/embeds/image.js Normal file
View file

@ -0,0 +1,13 @@
function embed_image(link_info) {
if (typeof link_info.extension !== "string") {
return;
}
const is_image = get_mime_types(link_info.extension).shift()?.indexOf("image") === 0;
if (!is_image) {
return;
}
return `<div class="embed-container image"><img src="${link_info.url}" alt="An image from ${link_info.domain}" /></div>`;
}

3
public/js/embeds/link.js Normal file
View file

@ -0,0 +1,3 @@
function embed_link(link_info) {
return `<a href="${link_info.url}">${link_info.url}</a>`;
}

13
public/js/embeds/mp4.js Normal file
View file

@ -0,0 +1,13 @@
function embed_mp4(link_info) {
if (typeof link_info.extension !== "string") {
return;
}
const is_mp4 = get_mime_types(link_info.extension).includes("video/mp4");
if (!is_mp4) {
return;
}
return `<div class="embed-container image gif letterbox"><video autoplay="true" muted="true" loop="true" playsinline="true"><source src="${link_info.url}" type="video/mp4"><a href="${link_info.url}">${link_info.url}</a></video></div>`;
}

View file

@ -0,0 +1,33 @@
const SPOTIFY_EXTRACTOR =
/^\/(?<item_type>(?:album|artist|episode|playlist|tracks?))\/?(?<item_id>[a-zA-Z0-9]{22})/gi;
function embed_spotify(link_info) {
const is_spotify_link = ["spotify.com"].includes(link_info.domain?.toLowerCase());
if (!is_spotify_link) {
return;
}
SPOTIFY_EXTRACTOR.lastIndex = 0;
const {
groups: { item_type, item_id },
} = SPOTIFY_EXTRACTOR.exec(link_info.path ?? "") ?? { groups: {} };
if (!item_id) {
return;
}
return `
<div class="embed-container iframe ${!item_type || item_type.toLowerCase().indexOf("track") === 0 ? "short" : "square"} spotify rounded">
<div class="embed-actions-container">
<button class="icon plus" onclick="console.log(\"close\");"/>
<button class="icon pause" onclick="console.log(\"stop\");"/>
</div>
<iframe
src="https://open.spotify.com/embed/${item_type ?? "track"}/${item_id}"
allowfullscreen
allow="clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"></iframe>
</div>`;
}

35
public/js/embeds/tidal.js Normal file
View file

@ -0,0 +1,35 @@
const TIDAL_EXTRACTOR =
/^\/(?:(?<action>.*?)\/)?(?<item_type>(?:album|artist|episode|playlist|tracks?))\/(?<item_id>[0-9]+)/gi;
function embed_tidal(link_info) {
const is_tidal_link = ["tidal.com", "tidalhi.fi"].includes(link_info.domain?.toLowerCase());
if (!is_tidal_link) {
return;
}
TIDAL_EXTRACTOR.lastIndex = 0;
const {
groups: { action, item_type, item_id },
} = TIDAL_EXTRACTOR.exec(link_info.path ?? "") ?? { groups: {} };
if (!(item_type && item_id)) {
return;
}
return `
<div class="embed-container iframe ${item_type.toLowerCase().indexOf("track") === 0 ? "short" : "square"} tidal">
<div class="embed-actions-container">
<button class="icon plus" onclick="console.log(\"close\");"/>
<button class="icon pause" onclick="console.log(\"stop\");"/>
</div>
<iframe
src="https://embed.tidal.com/${item_type.at(-1) === "s" ? item_type : `${item_type}s`}/${item_id}"
allow="encrypted-media"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
title="TIDAL Embed Player"
loading="lazy"
></iframe>
</div>`;
}

35
public/js/embeds/vimeo.js Normal file
View file

@ -0,0 +1,35 @@
const VIMEO_ID_EXTRACTOR =
/(?<video_domain>vimeo\.com)(?:\/(?<action>video|embed|watch|shorts|v))?.*(?:(?:\/|v=)(?<video_id>[A-Za-z0-9._%-]*))\S*/gi;
function embed_vimeo(link_info) {
const is_vimeo_link = ["vimeo.com"].includes(link_info.domain?.toLowerCase());
if (!is_vimeo_link) {
return;
}
VIMEO_ID_EXTRACTOR.lastIndex = 0;
const {
groups: { video_domain, action, video_id },
} = VIMEO_ID_EXTRACTOR.exec(link_info.url) ?? { groups: {} };
if (!video_id) {
return;
}
return `
<div class="embed-container iframe letterbox vimeo">
<div class="embed-actions-container">
<button class="icon plus" onclick="console.log(\"close\");"/>
<button class="icon pause" onclick="console.log(\"stop\");"/>
</div>
<iframe
src="https://player.vimeo.com/video/${video_id}"
frameborder="0"
allow="fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
referrerpolicy="strict-origin-when-cross-origin"
title="Star Trek: Legacy"
loading="lazy"></iframe>
</div>`;
}

View file

@ -0,0 +1,38 @@
const YOUTUBE_ID_EXTRACTOR =
/(?<video_domain>youtu(?:be\.com|\.be|be\.googleapis\.com))(?:\/(?<action>video|embed|watch|shorts|v))?.*(?:(?:\/|v=)(?<video_id>[A-Za-z0-9._%-]*))\S*/gi;
function embed_youtube(link_info) {
const is_youtube_link = ["youtube.com", "youtu.be", "youtube.googleapis.com"].includes(
link_info.domain?.toLowerCase(),
);
if (!is_youtube_link) {
return;
}
YOUTUBE_ID_EXTRACTOR.lastIndex = 0;
const {
groups: { video_domain, action, video_id },
} = YOUTUBE_ID_EXTRACTOR.exec(link_info.url) ?? { groups: {} };
if (!video_id) {
return;
}
return `
<div class="embed-container iframe ${action === "shorts" ? "vertical" : "letterbox"} youtube">
<div class="embed-actions-container">
<button class="icon plus" onclick="console.log(\"close\");"/>
<button class="icon pause" onclick="console.log(\"stop\");"/>
</div>
<iframe
src="https://www.youtube.com/embed/${video_id}"
title="YouTube video player"
allow="clipboard-write; encrypted-media; picture-in-picture; web-share;"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
loading="lazy"
></iframe>
</div>`;
}

55
public/js/htmlify.js Normal file
View file

@ -0,0 +1,55 @@
// wow https://stackoverflow.com/questions/3891641/regex-test-only-works-every-other-time
// watch out for places we need to set `lastIndex` ... :frown:
const EMBEDS = [
embed_tidal,
embed_spotify,
embed_youtube,
embed_vimeo,
embed_audio,
embed_gif,
embed_mp4,
embed_image,
embed_link,
];
const URL_MATCHING_REGEX =
/(?:(?<protocol>[a-zA-Z]+):)?(?:\/\/)?(?:(?<auth>(?<username>\S.+)\:(?<password>.+))\@)?(?<host>(?:(?<hostname>[-a-zA-Z0-9\.]+)\.)?(?<domain>(?:[-a-zA-Z0-9]+?\.(?<tld>[-a-zA-Z0-9]{2,64}))|localhost)(?:\:(?<port>[0-9]{1,6}))?)\b(?<path>[-a-zA-Z0-9@:%_{}\[\]<>\(\)\+.~&\/="]*?(?<extension>\.[^\.?/#"\n<>]+)?)(?:\?(?<query>[a-zA-Z0-9!$%&<>()*+,-\.\/\:\;\=\?\@_~"]+))?(?:#(?<hash>[a-zA-Z0-9!$&'()*+,-\.\/\:\;\=\?\@_~"]*?))?(?:$|\s)/gim;
function htmlify(content) {
let html_content = (content ?? "").replace(/\n/g, "<br/>");
let match;
URL_MATCHING_REGEX.lastIndex = 0;
while ((match = URL_MATCHING_REGEX.exec(content)) !== null) {
const url = match[0];
const {
groups: { protocol, host, hostname, domain, tld, path, extension, query, hash },
} = match;
const link_info = {
url,
protocol,
host,
hostname,
domain,
tld,
path,
extension,
query,
hash,
};
for (const embed of EMBEDS) {
const result = embed(link_info);
if (typeof result === "string") {
html_content = html_content.replace(url, result);
break;
}
}
}
return html_content;
}

View file

@ -1,263 +1,10 @@
// wow https://stackoverflow.com/questions/3891641/regex-test-only-works-every-other-time
// watch out for places we need to set `lastIndex` ... :frown:
const URL_MATCHING_REGEX =
/(?:(?<protocol>[a-zA-Z]+):)?(?:\/\/)?(?:(?<auth>(?<username>\S.+)\:(?<password>.+))\@)?(?<host>(?:(?<hostname>[-a-zA-Z0-9\.]+)\.)?(?<domain>(?:[-a-zA-Z0-9]+?\.(?<tld>[-a-zA-Z0-9]{2,64}))|localhost)(?:\:(?<port>[0-9]{1,6}))?)\b(?<path>[-a-zA-Z0-9@:%_{}\[\]<>\(\)\+.~&\/="]*?(?<extension>\.[^\.?/#"\n<>]+)?)(?:\?(?<query>[a-zA-Z0-9!$%&<>()*+,-\.\/\:\;\=\?\@_~"]+))?(?:#(?<hash>[a-zA-Z0-9!$&'()*+,-\.\/\:\;\=\?\@_~"]*?))?(?:$|\s)/gim;
const VIDEO_ID_EXTRACTOR =
/(?<video_domain>vimeo\.com|youtu(?:be\.com|\.be|be\.googleapis\.com))(?:\/(?<action>video|embed|watch|shorts|v))?.*(?:(?:\/|v=)(?<video_id>[A-Za-z0-9._%-]*))\S*/gi;
const SPOTIFY_EXTRACTOR =
/^\/(?<item_type>(?:album|artist|episode|playlist|tracks?))\/?(?<item_id>[a-zA-Z0-9]{22})/gi;
const TIDAL_EXTRACTOR =
/^\/(?:(?<action>.*?)\/)?(?<item_type>(?:album|artist|episode|playlist|tracks?))\/(?<item_id>[0-9]+)/gi;
const URL_MATCH_HANDLERS = [
// Tidal
(link_info) => {
const is_tidal_link = ["tidal.com", "tidalhi.fi"].includes(link_info.domain?.toLowerCase());
if (!is_tidal_link) {
return;
}
TIDAL_EXTRACTOR.lastIndex = 0;
const {
groups: { action, item_type, item_id },
} = TIDAL_EXTRACTOR.exec(link_info.path ?? "") ?? { groups: {} };
if (!(item_type && item_id)) {
return;
}
return `
<div class="embed-container iframe ${item_type.toLowerCase().indexOf("track") === 0 ? "short" : "square"} tidal">
<div class="embed-actions-container">
<button class="icon plus" onclick="console.log(\"close\");"/>
<button class="icon pause" onclick="console.log(\"stop\");"/>
</div>
<iframe
src="https://embed.tidal.com/${item_type.at(-1) === "s" ? item_type : `${item_type}s`}/${item_id}"
allow="encrypted-media"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
title="TIDAL Embed Player"
loading="lazy"
></iframe>
</div>`;
},
// Spotify
(link_info) => {
const is_spotify_link = ["spotify.com"].includes(link_info.domain?.toLowerCase());
if (!is_spotify_link) {
return;
}
SPOTIFY_EXTRACTOR.lastIndex = 0;
const {
groups: { item_type, item_id },
} = SPOTIFY_EXTRACTOR.exec(link_info.path ?? "") ?? { groups: {} };
if (!item_id) {
return;
}
return `
<div class="embed-container iframe ${!item_type || item_type.toLowerCase().indexOf("track") === 0 ? "short" : "square"} spotify rounded">
<div class="embed-actions-container">
<button class="icon plus" onclick="console.log(\"close\");"/>
<button class="icon pause" onclick="console.log(\"stop\");"/>
</div>
<iframe
src="https://open.spotify.com/embed/${item_type ?? "track"}/${item_id}"
allowfullscreen
allow="clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"></iframe>
</div>`;
},
// YouTube
(link_info) => {
const is_youtube_link = ["youtube.com", "youtu.be", "youtube.googleapis.com"].includes(
link_info.domain?.toLowerCase(),
);
if (!is_youtube_link) {
return;
}
VIDEO_ID_EXTRACTOR.lastIndex = 0;
const {
groups: { video_domain, action, video_id },
} = VIDEO_ID_EXTRACTOR.exec(link_info.url) ?? { groups: {} };
if (!video_id) {
return;
}
return `
<div class="embed-container iframe ${action === "shorts" ? "vertical" : "letterbox"} youtube">
<div class="embed-actions-container">
<button class="icon plus" onclick="console.log(\"close\");"/>
<button class="icon pause" onclick="console.log(\"stop\");"/>
</div>
<iframe
src="https://www.youtube.com/embed/${video_id}"
title="YouTube video player"
allow="clipboard-write; encrypted-media; picture-in-picture; web-share;"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
loading="lazy"
></iframe>
</div>`;
},
// Vimeo
(link_info) => {
const is_vimeo_link = ["vimeo.com"].includes(link_info.domain?.toLowerCase());
if (!is_vimeo_link) {
return;
}
VIDEO_ID_EXTRACTOR.lastIndex = 0;
const {
groups: { video_domain, action, video_id },
} = VIDEO_ID_EXTRACTOR.exec(link_info.url) ?? { groups: {} };
if (!video_id) {
return;
}
return `
<div class="embed-container iframe letterbox vimeo">
<div class="embed-actions-container">
<button class="icon plus" onclick="console.log(\"close\");"/>
<button class="icon pause" onclick="console.log(\"stop\");"/>
</div>
<iframe
src="https://player.vimeo.com/video/${video_id}"
frameborder="0"
allow="fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
referrerpolicy="strict-origin-when-cross-origin"
title="Star Trek: Legacy"
loading="lazy"></iframe>
</div>`;
},
// linkify generic url
(link_info) => {
// TODO: punycoding if something has unicode?
// const punycode = get_punycode();
// const punycoded_url = punycode.encode(match[0]);
if (typeof link_info.extension === "string") {
const mime_types = get_mime_types(link_info.extension);
if (mime_types.length) {
if (mime_types.includes("image/gif")) {
return `<div class="embed-container image gif"><img src="${link_info.url}" alt="A gif from ${link_info.domain}" /></div>`;
}
if (mime_types.includes("video/mp4")) {
return `<div class="embed-container image gif letterbox"><video autoplay="true" muted="true" loop="true" playsinline="true"><source src="${link_info.url}" type="video/mp4"><a href="${link_info.url}">${link_info.url}</a></video></div>`;
}
if (mime_types[0].indexOf("image") === 0) {
return `<div class="embed-container image"><img src="${link_info.url}" alt="An image from ${link_info.domain}" /></div>`;
}
if (mime_types[0].indexOf("audio") === 0) {
return `
<div class="audio-container" tabindex="-1">
<audio controls>
<source src="${link_info.url}" type="${mime_types[0].indexOf("audio/mpeg") === 0 ? "audio/mpeg" : mime_types[0]}">
Your browser does not support the audio element.
</audio>
<div class="enhanced-audio-player-container">
<div class="audio-player-display-container">
<canvas class="audio-player-display"></canvas>
</div>
<div class="audio-controls-container">
<div class="progress-container">
<div class="time-container"><span class="current">00:00</span></div>
<div class="slider-container">
<input type="range" name="progress" title="" min="0" max="1000" step="1" value="0" />
<label class="time-container" for="progress"><span class="current">00:00</span></label>
</div>
<div class="time-container"><span class="duration">00:00</span></div>
</div>
<div class="buttons-container">
<div class="audio-control blank">
<a href="${link_info.url}" download>
<div class="icon download"></div>
</a>
</div>
<div class="audio-control skip-back"><div class="icon skip-back"></div></div>
<div class="audio-control play-pause-toggle"><div class="icon play"></div><div class="icon pause"></div></div>
<div class="audio-control skip-forward"><div class="icon skip-forward"></div></div>
<div class="audio-control volume">
<input type="range" name="volume" title="Volume" min="0" max="100" step="1" value="" />
</div>
</div>
</div>
</div>
</div>`;
}
}
}
return `<a href="${link_info.url}">${link_info.url}</a>`;
},
];
function message_text_to_html(input) {
let html_message = (input ?? "").replace(/\n/g, "<br/>");
let match;
URL_MATCHING_REGEX.lastIndex = 0;
while ((match = URL_MATCHING_REGEX.exec(input)) !== null) {
const url = match[0];
const {
groups: { protocol, host, hostname, domain, tld, path, extension, query, hash },
} = match;
for (const handler of URL_MATCH_HANDLERS) {
const result = handler({
url,
protocol,
host,
hostname,
domain,
tld,
path,
extension,
query,
hash,
});
if (typeof result === "string") {
html_message = html_message.replace(url, result);
break;
}
}
}
return html_message;
}
const time_tick_tock_timeout = 60 * 1000; // 1 minute const time_tick_tock_timeout = 60 * 1000; // 1 minute
let last_event_datetime_value = 0; let last_event_datetime_value = 0;
let time_tick_tock_class = "time-tock"; let time_tick_tock_class = "time-tock";
let last_creator_id = null; let last_creator_id = null;
let user_tick_tock_class = "user-tock"; let user_tick_tock_class = "user-tock";
function render_text_event(topic_chat_content, event, creator, existing_element) { function render_chat_message(topic_chat_content, event, creator, existing_element) {
const event_datetime = datetime_to_local(event.timestamps.created); const event_datetime = datetime_to_local(event.timestamps.created);
if (event_datetime.value - last_event_datetime_value > time_tick_tock_timeout) { if (event_datetime.value - last_event_datetime_value > time_tick_tock_timeout) {
@ -299,7 +46,7 @@ function render_text_event(topic_chat_content, event, creator, existing_element)
<span class="short">${event_datetime.short}</span> <span class="short">${event_datetime.short}</span>
</div> </div>
</div> </div>
<div class="message-content-container">${message_text_to_html(event.data.content)}</div> <div class="message-content-container">${htmlify(event.data.content)}</div>
</div>`; </div>`;
if (existing_element) { if (existing_element) {
@ -311,8 +58,7 @@ function render_text_event(topic_chat_content, event, creator, existing_element)
} }
} }
const users = {}; async function append_chat_events(events) {
async function append_topic_events(events) {
const topic_chat_content = document.getElementById("topic-chat-content"); const topic_chat_content = document.getElementById("topic-chat-content");
let last_message_id = topic_chat_content.dataset.last_message_id ?? ""; let last_message_id = topic_chat_content.dataset.last_message_id ?? "";
for (const event of events) { for (const event of events) {
@ -328,16 +74,14 @@ async function append_topic_events(events) {
topic_chat_content.dataset.last_message_id = last_message_id; topic_chat_content.dataset.last_message_id = last_message_id;
} }
users[event.creator_id] = const creator = await USERS.get(event.creator_id);
users[event.creator_id] ??
(await (await api.fetch(`/api/users/${event.creator_id}`)).json());
const existing_element = const existing_element =
document.getElementById(`chat-${event.id.substring(0, 49)}`) ?? document.getElementById(`chat-${event.id.substring(0, 49)}`) ??
(event.meta?.temp_id (event.meta?.temp_id
? document.getElementById(`chat-${event.meta.temp_id}`) ? document.getElementById(`chat-${event.meta.temp_id}`)
: undefined); : undefined);
render_text_event(topic_chat_content, event, users[event.creator_id], existing_element); render_chat_message(topic_chat_content, event, creator, existing_element);
} }
topic_chat_content.scrollTop = topic_chat_content.scrollHeight; topic_chat_content.scrollTop = topic_chat_content.scrollHeight;
@ -347,7 +91,7 @@ async function append_topic_events(events) {
// similar for when we change topics, this is the most basic // similar for when we change topics, this is the most basic
// first pass outline // first pass outline
let topic_polling_request_abort_controller = null; let topic_polling_request_abort_controller = null;
async function poll_for_new_events() { async function poll_for_new_chat_events() {
const topic_chat_content = document.getElementById("topic-chat-content"); const topic_chat_content = document.getElementById("topic-chat-content");
const topic_id = document.body.dataset.topic; const topic_id = document.body.dataset.topic;
const last_message_id = topic_chat_content.dataset.last_message_id; const last_message_id = topic_chat_content.dataset.last_message_id;
@ -366,8 +110,8 @@ async function poll_for_new_events() {
}) })
.then(async (new_events_response) => { .then(async (new_events_response) => {
const new_events = ((await new_events_response.json()) ?? []).reverse(); const new_events = ((await new_events_response.json()) ?? []).reverse();
await append_topic_events(new_events.toReversed()); await append_chat_events(new_events.toReversed());
poll_for_new_events(topic_id); poll_for_new_chat_events(topic_id);
}) })
.catch((error) => { .catch((error) => {
// TODO: poll again? back off? // TODO: poll again? back off?
@ -375,7 +119,7 @@ async function poll_for_new_events() {
}); });
} }
async function load_active_topic() { async function load_active_topic_for_chat() {
const topic_id = document.body.dataset.topic; const topic_id = document.body.dataset.topic;
if (!topic_id) return; if (!topic_id) return;
@ -420,8 +164,8 @@ async function load_active_topic() {
const events = (await events_response.json()).reverse(); const events = (await events_response.json()).reverse();
await append_topic_events(events); await append_chat_events(events);
poll_for_new_events(); poll_for_new_chat_events();
} }
document.addEventListener("topic_changed", load_active_topic); document.addEventListener("topic_changed", load_active_topic_for_chat);
document.addEventListener("user_logged_in", load_active_topic); document.addEventListener("user_logged_in", load_active_topic_for_chat);