import { redirect } from '@sveltejs/kit'; import { goto } from '$app/navigation'; import * as api from './apiServer.js'; import * as r from './state/remote/state.svelte.js'; import * as l from './state/local/channels.svelte.js'; import { Watchdog } from './watchdog.js'; import { DateTime } from 'luxon'; import { render } from '$lib/markdown.js'; class Channel { static fromRemote({ at, id, name }, messages, meta) { const sentAt = messages .filter((message) => message.channel === id) .map((message) => message.at); const lastEventAt = Math.max(at, ...sentAt); const lastReadAt = meta.get(id)?.lastReadAt; const hasUnreads = lastReadAt === undefined || lastEventAt > lastReadAt; return new Channel({ at, id, name, hasUnreads }); } constructor({ at, id, name, hasUnreads }) { this.at = at; this.id = id; this.name = name; this.hasUnreads = hasUnreads; } } class Message { static fromRemote({ id, at, channel, sender, body, renderedBody }, users) { return new Message({ id, at, channel, sender: users.get(sender), body, renderedBody }); } constructor({ id, at, channel, sender, body, renderedBody }) { this.id = id; this.at = at; this.channel = channel; this.sender = sender; this.body = body; this.renderedBody = renderedBody; } } class Session { remote = $state(); local = $state(); currentUser = $derived(this.remote.currentUser); users = $derived(this.remote.users.all); messages = $derived( this.remote.messages.all.map((message) => Message.fromRemote(message, this.users)) ); channels = $derived( this.remote.channels.all.map((channel) => Channel.fromRemote(channel, this.messages, this.local.all) ) ); static boot({ user, users, channels, messages, resume_point, heartbeat }) { const remote = r.State.boot({ currentUser: user, users, channels, messages, resumePoint: resume_point, heartbeat }); const local = l.Channels.fromLocalStorage(); return new Session(remote, local); } reboot({ user, users, channels, messages, resume_point, heartbeat }) { this.remote = r.State.boot({ currentUser: user, users, channels, messages, resumePoint: resume_point, heartbeat }); } constructor(remote, local) { this.watchdog = new Watchdog(this.watchdogExpired.bind(this)); this.remote = remote; this.local = local; } begin() { this.events = api.subscribeToEvents(this.remote.resumePoint); this.events.onmessage = this.onMessage.bind(this); this.watchdog.reset(this.heartbeatMillis()); } end() { this.watchdog.stop(); this.events.close(); this.events = null; } active() { return this.events !== null; } onMessage(message) { const event = JSON.parse(message.data); this.remote.onEvent(event); this.local.retainChannels(this.remote.channels.all); this.watchdog.reset(this.heartbeatMillis()); } heartbeatMillis() { return this.remote.heartbeat /* in seconds */ * 1000 /* millis */; } async watchdogExpired() { // We leave `this.events` set here as a marker that the interruption is temporary. That's then // used below, after a potential delay, to decide whether to start the stream back up again or // not. this.events.close(); this.watchdog.stop(); const response = await bootOrNavigate(goto); // Session abandoned; give up here. We need to do this after each await, because that's time in // which the session may have been abandoned. if (!this.active()) return; this.reboot(response); this.begin(); } } async function bootOrNavigate(navigateTo) { try { const response = await api.retry(async () => await api.boot()); return response.data; } catch (err) { switch (true) { case err instanceof api.LoggedOut: await navigateTo('/login'); break; case err instanceof api.SetupRequired: await navigateTo('/setup'); break; default: throw err; } } } export async function boot() { const response = await bootOrNavigate(async (url) => redirect(307, url)); return Session.boot(response); }