diff options
Diffstat (limited to 'ui/lib')
| -rw-r--r-- | ui/lib/apiServer.js | 18 | ||||
| -rw-r--r-- | ui/lib/components/ActiveChannel.svelte | 15 | ||||
| -rw-r--r-- | ui/lib/components/Message.svelte | 25 | ||||
| -rw-r--r-- | ui/lib/components/MessageRun.svelte | 10 | ||||
| -rw-r--r-- | ui/lib/outbox.svelte.js | 111 | ||||
| -rw-r--r-- | ui/lib/runs.js | 3 | ||||
| -rw-r--r-- | ui/lib/state/remote/messages.svelte.js | 2 | ||||
| -rw-r--r-- | ui/lib/state/remote/state.svelte.js | 4 | ||||
| -rw-r--r-- | ui/lib/state/remote/users.svelte.js | 19 |
9 files changed, 175 insertions, 32 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index cad8997..e682681 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -87,5 +87,21 @@ function responseError(err) { } function isRetryable(err) { - return !!err.request; + // See <https://axios-http.com/docs/handling_errors> for a breakdown of this logic. + + // Any error with a response is non-retryable. The server responded; we get to act on that + // response. We don't do anything special for 5xx-series responses yet, but they might one day be + // retryable too. + if (err.response) { + return false; + } + + // Any error with no response and a request is probably from the network side of things, and is + // retryable. + if (err.request) { + return true; + } + + // Anything with neither is unexpected enough that we should not try it. + return false; } diff --git a/ui/lib/components/ActiveChannel.svelte b/ui/lib/components/ActiveChannel.svelte deleted file mode 100644 index c38d10d..0000000 --- a/ui/lib/components/ActiveChannel.svelte +++ /dev/null @@ -1,15 +0,0 @@ -<script> - import MessageRun from './MessageRun.svelte'; - - let { messageRuns, deleteMessage = async (id) => {} } = $props(); - - $effect(() => { - // This is just to force it to track messageRuns. - const _ = messageRuns; - document.querySelector('.message-run:last-child .message:last-child')?.scrollIntoView(); - }); -</script> - -{#each messageRuns as { sender, ownMessage, messages }} - <MessageRun {sender} {ownMessage} {messages} {deleteMessage} /> -{/each} diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte index edd9d79..5d15d17 100644 --- a/ui/lib/components/Message.svelte +++ b/ui/lib/components/Message.svelte @@ -1,7 +1,15 @@ <script> import { DateTime } from 'luxon'; - let { id, at, body, renderedBody, editable = false, deleteMessage = async (id) => {} } = $props(); + let { + class: cssClass, + id, + at, + body, + renderedBody, + editable = false, + deleteMessage = async (id) => {} + } = $props(); let deleteArmed = $state(false); let atFormatted = $derived(at.toLocaleString(DateTime.DATETIME_SHORT)); @@ -20,10 +28,21 @@ } </script> -<div class="message" class:delete-armed={deleteArmed} role="article" data-at={at} {onmouseleave}> +<div + class={[ + 'message', + { + ['delete-armed']: deleteArmed + }, + cssClass + ]} + role="article" + data-at={at} + {onmouseleave} +> <div class="handle"> {atFormatted} - {#if editable} + {#if editable && id} <button onclick={ondelete}>🗑️</button> {/if} </div> diff --git a/ui/lib/components/MessageRun.svelte b/ui/lib/components/MessageRun.svelte index f1facd3..f6e7ee1 100644 --- a/ui/lib/components/MessageRun.svelte +++ b/ui/lib/components/MessageRun.svelte @@ -1,14 +1,10 @@ <script> - import Message from '$lib/components/Message.svelte'; - - let { sender, messages, ownMessage, deleteMessage = async (id) => {} } = $props(); + let { sender, children, class: cssClass } = $props(); </script> -<div class="message-run" class:own-message={ownMessage} class:other-message={!ownMessage}> +<div class={['message-run', cssClass]}> <span class="username"> @{sender}: </span> - {#each messages as message} - <Message {...message} editable={ownMessage} {deleteMessage} /> - {/each} + {@render children?.()} </div> diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js new file mode 100644 index 0000000..472c58b --- /dev/null +++ b/ui/lib/outbox.svelte.js @@ -0,0 +1,111 @@ +import { DateTime } from 'luxon'; +import * as msg from './state/remote/messages.svelte.js'; + +import * as api from './apiServer.js'; +import * as md from './markdown.js'; + +class PostToChannel { + constructor(channel, body) { + this.channel = channel; + this.body = body; + this.at = DateTime.now(); + this.renderedBody = md.render(body); + } + + toSkeleton(sender) { + return { + id: null, + at: this.at, + channel: this.channel, + sender, + body: this.body, + renderedBody: this.renderedBody + }; + } + + async send() { + return await api.retry(() => api.postToChannel(this.channel, this.body)); + } +} + +class DeleteMessage { + constructor(messageId) { + this.messageId = messageId; + } + + async send() { + return await api.retry(() => api.deleteMessage(this.messageId)); + } +} + +class CreateChannel { + constructor(name) { + this.name = name; + } + + async send() { + return await api.retry(() => api.createChannel(this.name)); + } +} + +export class Outbox { + pending = $state([]); + messages = $derived(this.pending.filter((operation) => operation instanceof PostToChannel)); + deleted = $derived(this.pending.filter((operation) => operation instanceof DeleteMessage)); + + static empty() { + return new Outbox([]); + } + + constructor(pending) { + this.pending = pending; + } + + enqueue(operation) { + this.pending.push(operation); + this.start(); + } + + createChannel(name) { + this.enqueue(new CreateChannel(name)); + } + + postToChannel(channel, body) { + this.enqueue(new PostToChannel(channel, body)); + } + + deleteMessage(messageId) { + this.enqueue(new DeleteMessage(messageId)); + } + + start() { + if (this.sending) { + return; + } + // This is a promise transform primarily to keep the management of `this.sending` in one place, + // rather than spreading it across multiple methods. + this.sending = this.drain().finally(() => { + this.sending = null; + + // If we encounter an exception processing the pending queue, it may have an operation left + // in it. If so, start over. The exception will still propagate out (though since nothing + // ever awaits the promise from this.sending, it'll ultimately leak out to the browser + // anyways). + if (this.pending.length > 0) { + this.start(); + } + }); + } + + async drain() { + while (this.pending.length > 0) { + const operation = this.pending[0]; + + try { + await operation.send(); + } finally { + this.pending.shift(); + } + } + } +} diff --git a/ui/lib/runs.js b/ui/lib/runs.js index f4e90be..de00e6a 100644 --- a/ui/lib/runs.js +++ b/ui/lib/runs.js @@ -1,4 +1,5 @@ import * as iter from './iterator.js'; +import { User } from './state/remote/users.svelte.js'; const RUN_COALESCE_MAX_INTERVAL = 10 /* min */ * 60 /* sec */ * 1000; /* ms */ @@ -21,5 +22,5 @@ function runKey(message) { } function continueRun([lastSender, lastAt], [newSender, newAt]) { - return lastSender === newSender && newAt - lastAt < RUN_COALESCE_MAX_INTERVAL; + return User.equal(lastSender, newSender) && newAt - lastAt < RUN_COALESCE_MAX_INTERVAL; } diff --git a/ui/lib/state/remote/messages.svelte.js b/ui/lib/state/remote/messages.svelte.js index 576a74e..c6d31f0 100644 --- a/ui/lib/state/remote/messages.svelte.js +++ b/ui/lib/state/remote/messages.svelte.js @@ -1,7 +1,7 @@ import { DateTime } from 'luxon'; import { render } from '$lib/markdown.js'; -class Message { +export class Message { static boot({ id, at, channel, sender, body }) { return new Message({ id, diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js index 29831a0..e00d55c 100644 --- a/ui/lib/state/remote/state.svelte.js +++ b/ui/lib/state/remote/state.svelte.js @@ -1,4 +1,4 @@ -import { Users } from './users.svelte.js'; +import { User, Users } from './users.svelte.js'; import { Channels } from './channels.svelte.js'; import { Messages } from './messages.svelte.js'; @@ -10,7 +10,7 @@ export class State { static boot({ currentUser, heartbeat, users, channels, messages, resumePoint }) { return new State({ - currentUser, + currentUser: User.boot(currentUser), heartbeat, users: Users.boot(users), channels: Channels.boot(channels), diff --git a/ui/lib/state/remote/users.svelte.js b/ui/lib/state/remote/users.svelte.js index 617084f..a15d1da 100644 --- a/ui/lib/state/remote/users.svelte.js +++ b/ui/lib/state/remote/users.svelte.js @@ -1,10 +1,25 @@ import { SvelteMap } from 'svelte/reactivity'; +export class User { + static equal(a, b) { + return a.id === b.id && a.name === b.name; + } + + static boot({ id, name }) { + return new User(id, name); + } + + constructor(id, name) { + this.id = id; + this.name = name; + } +} + export class Users { all = $state(); static boot(users) { - const all = new SvelteMap(users.map((user) => [user.id, user])); + const all = new SvelteMap(users.map((user) => [user.id, User.boot(user)])); return new Users({ all }); } @@ -13,6 +28,6 @@ export class Users { } add({ id, name }) { - this.all.set(id, { id, name }); + this.all.set(id, new User(id, name)); } } |
