From 1035eb815f5a4996d8f546aa4b85da29ccea5d73 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 2 May 2025 01:24:10 -0400 Subject: Send messages through an outbox, rather than sending them to the API directly from the UI. This primarily serves to free up the message input immediately, so that the user can start drafting their next message right away. The wait while a message is being sent is actively disruptive when using Pilcrow on a server with noticable latency (hi.grimoire.ca has around 700ms), and this largely alleviates it. Unsent messages can be lost if the client is closed or deactivated before they make it to the head of the queue. --- ui/lib/outbox.svelte.js | 47 +++++++++++++++++++++++++++++++ ui/routes/(app)/+layout.js | 2 ++ ui/routes/(app)/ch/[channel]/+page.svelte | 4 +-- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 ui/lib/outbox.svelte.js diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js new file mode 100644 index 0000000..0681f29 --- /dev/null +++ b/ui/lib/outbox.svelte.js @@ -0,0 +1,47 @@ +import * as api from './apiServer.js'; +import * as md from './markdown.js'; + +class Message { + constructor(channel, body) { + this.channel = channel; + this.body = body; + this.renderedBody = md.render(body); + } +} + +export class Outbox { + pending = $state([]); + + static empty() { + return new Outbox([]); + } + + constructor(pending) { + this.pending = pending; + } + + send(channel, body) { + this.pending.push(new Message(channel, body)); + this.start(); + } + + 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; + }); + } + + async drain() { + while (this.pending.length > 0) { + const { channel, body } = this.pending[0]; + + await api.retry(() => api.postToChannel(channel, body)); + this.pending.shift(); + } + } +} diff --git a/ui/routes/(app)/+layout.js b/ui/routes/(app)/+layout.js index 651bc8c..9c0afa8 100644 --- a/ui/routes/(app)/+layout.js +++ b/ui/routes/(app)/+layout.js @@ -1,8 +1,10 @@ import * as session from '$lib/session.svelte.js'; +import { Outbox } from '$lib/outbox.svelte.js'; export async function load() { let s = await session.boot(); return { + outbox: Outbox.empty(), session: s }; } diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index c8507cc..9506b67 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -7,7 +7,7 @@ import * as api from '$lib/apiServer'; const { data } = $props(); - const { session } = data; + const { session, outbox } = data; let activeChannel; const channel = $derived(page.params.channel); @@ -65,7 +65,7 @@ } async function sendMessage(message) { - await api.postToChannel(channel, message); + outbox.send(channel, message); } async function deleteMessage(id) { -- cgit v1.2.3 From 8a329eb962eb98e89b41708d92b3f9298e4c21e1 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 2 May 2025 02:13:19 -0400 Subject: Warn the user before navigating away, when the outbox has messages in it. This is in lieu of saving the outbox. I tried that, and: * If messages are dropped from the saved outbox before calling `api.postToChannel`, then messages "in flight" are lost when the page is reloaded unless the send succeeds after the client vanishes, as they are not re-sent when the page loads. * If messages are dropped from the saved outbox after calling `api.postToChannel`, then messages "in flight" are duplicated when the page is reloaded and they get re-sent. The CAP theorem is real and can hurt you. The appropriate compensating mechanism would be a client-generated per-operation nonce, with server-side support for replaying responses by nonce if an operation already completed. That's a pretty big undertaking, and it's one we should probably do, but it's larger than I want to take on right now. Instead, we warn the user, and they can make their own decision. Except we don't, sometimes. When the client runs in a browser, this event handler prompts the user for confirmation before reloading, navigating away, closing the tab, or quitting. When run in a Safari app container, though, it only warns before reloading. Closing the window or quitting the app do not provoke a prompt. The warning is "best" effort. The failure mode is lost messages, which isn't particularly best. --- ui/routes/(app)/+layout.svelte | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 1ba3fa9..116767d 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -14,7 +14,7 @@ let gesture = null; const { data, children } = $props(); - const { session } = data; + const { session, outbox } = data; onMount(session.begin.bind(session)); onDestroy(session.end.bind(session)); @@ -89,8 +89,44 @@ async function createChannel(name) { await api.createChannel(name); } + + function onbeforeunload(event) { + if (outbox.pending.length > 0) { + // Prompt the user that they have unsaved (unsent) messages that may be lost. + event.preventDefault(); + } + } + + + pilcrow -- cgit v1.2.3 From d11ff421de581a835f1645b22d1f7f4304640b0c Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 5 May 2025 19:51:58 -0400 Subject: Use the outbox for more than just message sends. A handful of operations are "synchronized" - that is, the server sends back information about them when the client asks to perform them, but notifies _all_ clients of completion through the event stream. As of this writing, these operations include sending and deleting messages, creating and deleting channels, and anything that creates new users. We can use the outbox for most of these. I've opted _not_ to use the outbox for creating users, as that often takes place when the client is not connected to the event stream (and can't be) and so cannot discover that the operation has been completed after it is sent. Outboxed tasks are objects, not closures, even though they behave in closure-like ways (`send()` carries out what amounts to "attempt this operation until it succeeds"). This is deliberate; I want the properties of incomplete tasks to be inspectable down the line, so that we can put them in the UI. If they're mere closures, we can't do that. It is deliberate that `outbox.postToChannel` et al do not return promises. I contemplated it, but it will interact weirdly with outbox serialization, when we get to that. Rather than relying on a promise to determine when an operation has completed, clients of `Outbox` should monitor its properties. (We can add additional view properties to make that easier.) --- ui/lib/outbox.svelte.js | 46 +++++++++++++++++++++++++++---- ui/routes/(app)/+layout.svelte | 2 +- ui/routes/(app)/ch/[channel]/+page.svelte | 5 ++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js index 0681f29..de7f80e 100644 --- a/ui/lib/outbox.svelte.js +++ b/ui/lib/outbox.svelte.js @@ -1,12 +1,36 @@ import * as api from './apiServer.js'; import * as md from './markdown.js'; -class Message { +class PostToChannel { constructor(channel, body) { this.channel = channel; this.body = body; this.renderedBody = md.render(body); } + + 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 { @@ -20,11 +44,23 @@ export class Outbox { this.pending = pending; } - send(channel, body) { - this.pending.push(new Message(channel, body)); + 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; @@ -38,9 +74,9 @@ export class Outbox { async drain() { while (this.pending.length > 0) { - const { channel, body } = this.pending[0]; + const operation = this.pending[0]; - await api.retry(() => api.postToChannel(channel, body)); + await operation.send(); this.pending.shift(); } } diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 116767d..a4ae442 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -87,7 +87,7 @@ }); async function createChannel(name) { - await api.createChannel(name); + outbox.createChannel(name); } function onbeforeunload(event) { diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index 9506b67..abec00d 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -4,7 +4,6 @@ import ActiveChannel from '$lib/components/ActiveChannel.svelte'; import MessageInput from '$lib/components/MessageInput.svelte'; import { runs } from '$lib/runs.js'; - import * as api from '$lib/apiServer'; const { data } = $props(); const { session, outbox } = data; @@ -65,11 +64,11 @@ } async function sendMessage(message) { - outbox.send(channel, message); + outbox.postToChannel(channel, message); } async function deleteMessage(id) { - await api.deleteMessage(id); + outbox.deleteMessage(id); } -- cgit v1.2.3 From 84c1fa7ccef4e8e49f17d643f360ec0f184683fb Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 5 May 2025 23:12:22 -0400 Subject: Don't retry operations where we received an unacceptable response. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was actually two issues in one! Issue 1: `isRetryable` did not consider whether we got a response or not. It assumed that the presence of a request in the error signaled that the error was definitely due to network issues, when in fact it's the presence of a request _and_ the absence of a response. That's my misreading of the Axios docs; the replacement `isRetryable` is more thorough. Issue 2: operations in the outbox queue that fail with an exception stop the outbox drain process from making further progress, _and_ they stay in the queue. The outbox now dequeues jobs that throw an exception, and restarts itself if it terminates with a non-empty queue. The code that does this is _heinous_, but it seems to work well enough… Words I'm sure I won't come to regret. --- ui/lib/apiServer.js | 18 +++++++++++++++++- ui/lib/outbox.svelte.js | 15 +++++++++++++-- 2 files changed, 30 insertions(+), 3 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 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/outbox.svelte.js b/ui/lib/outbox.svelte.js index de7f80e..fd7fdba 100644 --- a/ui/lib/outbox.svelte.js +++ b/ui/lib/outbox.svelte.js @@ -69,6 +69,14 @@ export class Outbox { // 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(); + } }); } @@ -76,8 +84,11 @@ export class Outbox { while (this.pending.length > 0) { const operation = this.pending[0]; - await operation.send(); - this.pending.shift(); + try { + await operation.send(); + } finally { + this.pending.shift(); + } } } } -- cgit v1.2.3 From 5243c91d3a7c325088845cef198c2eb8906babc0 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 6 May 2025 00:46:01 -0400 Subject: Use a more targetted selector to control link text colours inside of messages. Using a wildcard selector here makes this rule surprisingly hard to override, which will be a problem for styling unsent messages. --- ui/styles/messages.css | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/ui/styles/messages.css b/ui/styles/messages.css index 4890f2c..5890c1a 100644 --- a/ui/styles/messages.css +++ b/ui/styles/messages.css @@ -8,26 +8,22 @@ overflow: hidden; } -.own-message { +.own-message, +.own-message :link { background-color: var(--colour-message-run-self-bg); + color: var(--colour-message-run-self-text); border: 1px solid var(--colour-message-run-self-border); margin-left: 1rem; } -.own-message * { - color: var(--colour-message-run-self-text); -} - -.other-message { +.other-message, +.other-message :link { background-color: var(--colour-message-run-other-bg); + color: var(--colour-message-run-other-text); border: 1px solid var(--colour-message-run-other-border); margin-right: 1rem; } -.other-message * { - color: var(--colour-message-run-other-text); -} - .message-run > .username { background-color: var(--colour-message-run-username-bg); color: var(--colour-message-run-username-text); -- cgit v1.2.3 From adc1aef3dbc6b9d5f08f215d527a37e7cc59ddd9 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 6 May 2025 00:51:06 -0400 Subject: Un-nest `Message` from `MessageRun`. A `MessageRun` is a visual container with a specific layout - bordered, with a drop shadow, with a name badge on the top-left, which is either positioned to the left (`other-message`) or right (`own-message`). It is content-agnostic. This facilitates putting things besides live messages inside of a message run. As a side effect, this gets rid of ActiveChannel; most of what it was doing makes more sense living in the channel view's `+page.svelte`. --- ui/lib/components/ActiveChannel.svelte | 15 --------------- ui/lib/components/MessageRun.svelte | 10 +++------- ui/routes/(app)/ch/[channel]/+page.svelte | 23 +++++++++++++++++++++-- 3 files changed, 24 insertions(+), 24 deletions(-) delete mode 100644 ui/lib/components/ActiveChannel.svelte diff --git a/ui/lib/components/ActiveChannel.svelte b/ui/lib/components/ActiveChannel.svelte deleted file mode 100644 index 30b8385..0000000 --- a/ui/lib/components/ActiveChannel.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -{#each messageRuns as { sender, ownMessage, messages }} - -{/each} 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 @@ -
+
@{sender}: - {#each messages as message} - - {/each} + {@render children?.()}
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index abec00d..ccf455c 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -1,8 +1,9 @@ -
+
{atFormatted} - {#if editable} + {#if editable && id} {/if}
diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js index fd7fdba..0e4cf29 100644 --- a/ui/lib/outbox.svelte.js +++ b/ui/lib/outbox.svelte.js @@ -1,3 +1,6 @@ +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'; @@ -5,9 +8,21 @@ 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)); } @@ -35,6 +50,7 @@ class CreateChannel { export class Outbox { pending = $state([]); + messages = $derived(this.pending.filter((operation) => operation instanceof PostToChannel)); static empty() { return new Outbox([]); diff --git a/ui/lib/runs.js b/ui/lib/runs.js index f4e90be..e3d4c20 100644 --- a/ui/lib/runs.js +++ b/ui/lib/runs.js @@ -21,5 +21,13 @@ function runKey(message) { } function continueRun([lastSender, lastAt], [newSender, newAt]) { - return lastSender === newSender && newAt - lastAt < RUN_COALESCE_MAX_INTERVAL; + const { id: lastId, name: lastName } = lastSender; + const { id: newId, name: newName } = newSender; + if (lastId !== newId) { + return false; + } + if (lastName !== newName) { + return false; + } + return 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/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index ccf455c..50b6a7d 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -12,7 +12,11 @@ const channel = $derived(page.params.channel); const messages = $derived(session.messages.filter((message) => message.channel === channel)); - const messageRuns = $derived(runs(messages, session.currentUser)); + const unsent = $derived(outbox.messages.filter((message) => message.channel === channel)); + const unsentSkeletons = $derived( + unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser))) + ); + const messageRuns = $derived(runs(messages.concat(unsentSkeletons), session.currentUser)); function inView(parentElement, element) { const parRect = parentElement.getBoundingClientRect(); diff --git a/ui/styles/messages.css b/ui/styles/messages.css index 5890c1a..67a9517 100644 --- a/ui/styles/messages.css +++ b/ui/styles/messages.css @@ -39,6 +39,10 @@ position: relative; } +.message.unsent { + color: var(--colour-message-run-unsent-text); +} + .message.delete-armed, .message.delete-armed:hover { background-color: var(--colour-warn); diff --git a/ui/styles/variables.css b/ui/styles/variables.css index c43832c..01efc19 100644 --- a/ui/styles/variables.css +++ b/ui/styles/variables.css @@ -56,6 +56,7 @@ ); --colour-message-run-self-text: var(--dark-text); --colour-message-run-other-text: var(--dark-text); + --colour-message-run-unsent-text: var(--light-text); --colour-message-run-username-bg: color-mix(in srgb, var(--colour-base) 70%, white); --colour-message-run-username-border: color-mix(in srgb, var(--colour-base) 50%, black); -- cgit v1.2.3 From a01b516844c2a89af9446864b3eccccdd5afb9dc Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 6 May 2025 01:28:28 -0400 Subject: Render messages as ghosts when there's a pending delete, too. --- ui/lib/components/Message.svelte | 20 ++++++++++++++++---- ui/lib/outbox.svelte.js | 1 + ui/routes/(app)/ch/[channel]/+page.svelte | 11 ++++++++++- ui/styles/messages.css | 3 ++- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte index ea90414..5d15d17 100644 --- a/ui/lib/components/Message.svelte +++ b/ui/lib/components/Message.svelte @@ -1,7 +1,15 @@
operation instanceof PostToChannel)); + deleted = $derived(this.pending.filter((operation) => operation instanceof DeleteMessage)); static empty() { return new Outbox([]); diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index 50b6a7d..33a9bdf 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -13,6 +13,7 @@ const channel = $derived(page.params.channel); const messages = $derived(session.messages.filter((message) => message.channel === channel)); const unsent = $derived(outbox.messages.filter((message) => message.channel === channel)); + const deleted = $derived(outbox.deleted.map((message) => message.messageId)); const unsentSkeletons = $derived( unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser))) ); @@ -95,7 +96,15 @@ }} > {#each messages as message} - + {/each} {/each} diff --git a/ui/styles/messages.css b/ui/styles/messages.css index 67a9517..30f6db9 100644 --- a/ui/styles/messages.css +++ b/ui/styles/messages.css @@ -39,7 +39,8 @@ position: relative; } -.message.unsent { +.message.unsent, +.message.deleted { color: var(--colour-message-run-unsent-text); } -- cgit v1.2.3 From 92266a13bfabf7b29f08bc85d0e8efba467167da Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Thu, 8 May 2025 19:40:11 -0400 Subject: Rather than exploding a user into properties inside `runs`, use a helper method. --- ui/lib/runs.js | 11 ++--------- ui/lib/state/remote/state.svelte.js | 4 ++-- ui/lib/state/remote/users.svelte.js | 19 +++++++++++++++++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/ui/lib/runs.js b/ui/lib/runs.js index e3d4c20..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,13 +22,5 @@ function runKey(message) { } function continueRun([lastSender, lastAt], [newSender, newAt]) { - const { id: lastId, name: lastName } = lastSender; - const { id: newId, name: newName } = newSender; - if (lastId !== newId) { - return false; - } - if (lastName !== newName) { - return false; - } - return newAt - lastAt < RUN_COALESCE_MAX_INTERVAL; + return User.equal(lastSender, newSender) && newAt - lastAt < RUN_COALESCE_MAX_INTERVAL; } 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)); } } -- cgit v1.2.3