summaryrefslogtreecommitdiff
path: root/ui/lib
diff options
context:
space:
mode:
Diffstat (limited to 'ui/lib')
-rw-r--r--ui/lib/apiServer.js18
-rw-r--r--ui/lib/components/ActiveChannel.svelte15
-rw-r--r--ui/lib/components/Message.svelte25
-rw-r--r--ui/lib/components/MessageRun.svelte10
-rw-r--r--ui/lib/outbox.svelte.js111
-rw-r--r--ui/lib/runs.js3
-rw-r--r--ui/lib/state/remote/messages.svelte.js2
-rw-r--r--ui/lib/state/remote/state.svelte.js4
-rw-r--r--ui/lib/state/remote/users.svelte.js19
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}>&#x1F5D1;&#xFE0F;</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));
}
}