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'; class Channel { static fromRemote({ at, id, name }, messages, meta) { const sentAt = messages .filter((message) => message.conversation === id) .map((message) => message.at); const lastEventAt = DateTime.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, conversation, sender, body, renderedBody }, users) { return new Message({ id, at, conversation, sender: users.get(sender), body, renderedBody, }); } constructor({ id, at, conversation, sender, body, renderedBody }) { this.id = id; this.at = at; this.conversation = conversation; 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, resume_point, heartbeat, events }) { const remote = r.State.boot({ currentUser: user, resumePoint: resume_point, heartbeat, events, }); const local = l.Channels.fromLocalStorage(); return new Session(remote, local); } reboot({ user, resume_point, heartbeat, events }) { this.remote = r.State.boot({ currentUser: user, resumePoint: resume_point, heartbeat, events, }); } 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); }