From ad4ac3e10d2a3e5569c1b36f87d6a5f78a9cf863 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 13 May 2025 23:03:56 -0400 Subject: Make creation time an intrinsic fact about channels, the way it is for events. To make unread handling of empty channels coherent (and to make it possible to mark an empty channel as having been read), they need to be associated with a specific point in time. This change exposes their creation time in the snapshot - it was already part of the event view, though the client doesn't know that yet. --- docs/api/boot.md | 10 ++++++---- src/channel/event.rs | 4 +--- src/channel/history.rs | 2 -- src/channel/repo.rs | 10 +++++----- src/channel/snapshot.rs | 4 +++- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/api/boot.md b/docs/api/boot.md index 46b972f..2b9cf00 100644 --- a/docs/api/boot.md +++ b/docs/api/boot.md @@ -51,6 +51,7 @@ This endpoint will respond with a status of ], "channels": [ { + "at": "2025-04-14T23:58:11.421901Z", "name": "nonsense and such", "id": "C1234abcd" } @@ -94,10 +95,11 @@ Each element of the `users` array describes a distinct user, and will include th Each element of the `channels` array describes a distinct channel, and will include the following fields: -| Field | Type | Description | -|:-------|:-------|:----------------------------------------------------------------------------------------------------------------------------------------------| -| `name` | string | The name for the channel. | -| `id` | string | A unique identifier for the channel. This can be used to associate the channel with other events, or to make API calls targeting the channel. | +| Field | Type | Description | +|:-------|:----------|:----------------------------------------------------------------------------------------------------------------------------------------------| +| `at` | timestamp | The moment the channel was created. | +| `name` | string | The name for the channel. | +| `id` | string | A unique identifier for the channel. This can be used to associate the channel with other events, or to make API calls targeting the channel. | Each element of the `messages` array describes a distinct message, and will include the following fields: diff --git a/src/channel/event.rs b/src/channel/event.rs index f3dca3e..a5739f9 100644 --- a/src/channel/event.rs +++ b/src/channel/event.rs @@ -14,7 +14,7 @@ pub enum Event { impl Sequenced for Event { fn instant(&self) -> Instant { match self { - Self::Created(event) => event.instant, + Self::Created(event) => event.channel.created, Self::Deleted(event) => event.instant, } } @@ -22,8 +22,6 @@ impl Sequenced for Event { #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Created { - #[serde(flatten)] - pub instant: Instant, #[serde(flatten)] pub channel: Channel, } diff --git a/src/channel/history.rs b/src/channel/history.rs index 4af46ce..faf6a0e 100644 --- a/src/channel/history.rs +++ b/src/channel/history.rs @@ -9,7 +9,6 @@ use crate::event::{Instant, Sequence}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct History { pub channel: Channel, - pub created: Instant, pub deleted: Option, } @@ -50,7 +49,6 @@ impl History { fn created(&self) -> Event { Created { - instant: self.created, channel: self.channel.clone(), } .into() diff --git a/src/channel/repo.rs b/src/channel/repo.rs index 91f245b..812a259 100644 --- a/src/channel/repo.rs +++ b/src/channel/repo.rs @@ -57,11 +57,11 @@ impl Channels<'_> { let channel = History { channel: Channel { + created, id, name: name.clone(), deleted_at: None, }, - created, deleted: None, }; @@ -91,11 +91,11 @@ impl Channels<'_> { .map(|row| { Ok::<_, name::Error>(History { channel: Channel { + created: Instant::new(row.created_at, row.created_sequence), id: row.id, name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), deleted_at: row.deleted_at, }, - created: Instant::new(row.created_at, row.created_sequence), deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) }) @@ -129,11 +129,11 @@ impl Channels<'_> { .map(|row| { Ok::<_, name::Error>(History { channel: Channel { + created: Instant::new(row.created_at, row.created_sequence), id: row.id, name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), deleted_at: row.deleted_at, }, - created: Instant::new(row.created_at, row.created_sequence), deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) }) @@ -168,11 +168,11 @@ impl Channels<'_> { .map(|row| { Ok::<_, name::Error>(History { channel: Channel { + created: Instant::new(row.created_at, row.created_sequence), id: row.id, name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), deleted_at: row.deleted_at, }, - created: Instant::new(row.created_at, row.created_sequence), deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) }) @@ -299,11 +299,11 @@ impl Channels<'_> { .map(|row| { Ok::<_, name::Error>(History { channel: Channel { + created: Instant::new(row.created_at, row.created_sequence), id: row.id, name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), deleted_at: row.deleted_at, }, - created: Instant::new(row.created_at, row.created_sequence), deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) }) diff --git a/src/channel/snapshot.rs b/src/channel/snapshot.rs index 046ac38..96801b8 100644 --- a/src/channel/snapshot.rs +++ b/src/channel/snapshot.rs @@ -2,10 +2,12 @@ use super::{ Id, event::{Created, Event}, }; -use crate::{clock::DateTime, name::Name}; +use crate::{clock::DateTime, event::Instant, name::Name}; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Channel { + #[serde(flatten)] + pub created: Instant, pub id: Id, pub name: Name, #[serde(skip_serializing_if = "Option::is_none")] -- cgit v1.2.3 From c8bfcf0da05de75dc5c1da9c37cb9302e7268df1 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 13 May 2025 23:51:55 -0400 Subject: Track created-at times for each channel. --- ui/lib/state/remote/channels.svelte.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/ui/lib/state/remote/channels.svelte.js b/ui/lib/state/remote/channels.svelte.js index 64edb09..b6da31b 100644 --- a/ui/lib/state/remote/channels.svelte.js +++ b/ui/lib/state/remote/channels.svelte.js @@ -1,10 +1,27 @@ +import { DateTime } from 'luxon'; import { SvelteMap } from 'svelte/reactivity'; +class Channel { + static boot({ at, id, name }) { + return new Channel({ + at: DateTime.fromISO(at), + id, + name + }); + } + + constructor({ at, id, name }) { + this.at = at; + this.id = id; + this.name = name; + } +} + export class Channels { all = $state(); static boot(channels) { - const all = new SvelteMap(channels.map((channel) => [channel.id, channel])); + const all = new SvelteMap(channels.map((channel) => [channel.id, Channel.boot(channel)])); return new Channels({ all }); } @@ -12,8 +29,8 @@ export class Channels { this.all = all; } - add({ id, name }) { - this.all.set(id, { id, name }); + add({ at, id, name }) { + this.all.set(id, Channel.boot({ at, id, name })); } remove(id) { -- cgit v1.2.3 From 82cddbe9dd4cd2e231391d6b0c5355d78d88ce14 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 14 May 2025 00:10:17 -0400 Subject: Consider when a channel was created as part of determining whether it has been read. --- ui/routes/(app)/+layout.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index a4ae442..90fa82e 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -29,11 +29,13 @@ function enrichChannels(channels, channelsMeta, messages) { const enrichedChannels = []; for (const ch of channels.values()) { + const createdAt = ch.at; const channelMessages = messages.filter((message) => message.channel === ch.id); - const lastMessage = channelMessages.slice(-1)[0]; - const lastMessageAt = lastMessage?.at; + const sentAt = channelMessages.map((message) => message.at); + const lastEventAt = Math.max(createdAt, ...sentAt); + const lastReadAt = channelsMeta.get(ch.id)?.lastReadAt; - const hasUnreads = lastReadAt === undefined || lastMessageAt > lastReadAt; + const hasUnreads = lastReadAt === undefined || lastEventAt > lastReadAt; enrichedChannels.push({ ...ch, hasUnreads -- cgit v1.2.3 From 63291a543aac293d91f2f64faf47bb48886b59c4 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 14 May 2025 00:13:48 -0400 Subject: When there's no message in view, update last read time based on the channel's creation time. --- ui/routes/(app)/ch/[channel]/+page.svelte | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte index 33a9bdf..1130cd4 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/ch/[channel]/+page.svelte @@ -10,9 +10,10 @@ const { session, outbox } = data; let activeChannel; - 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 channelId = $derived(page.params.channel); + const channel = $derived(session.channels.get(channelId)); + const messages = $derived(session.messages.filter((message) => message.channel === channelId)); + const unsent = $derived(outbox.messages.filter((message) => message.channel === channelId)); const deleted = $derived(outbox.deleted.map((message) => message.messageId)); const unsentSkeletons = $derived( unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser))) @@ -45,10 +46,8 @@ function setLastRead() { const lastInView = getLastVisibleMessage(); - if (lastInView) { - const at = DateTime.fromISO(lastInView.dataset.at); - session.local.updateLastReadAt(channel, at); - } + const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : channel.at; + session.local.updateLastReadAt(channelId, at); } $effect(() => { @@ -76,7 +75,7 @@ } async function sendMessage(message) { - outbox.postToChannel(channel, message); + outbox.postToChannel(channelId, message); } async function deleteMessage(id) { -- cgit v1.2.3 From ae93188f0f4f36086622636ba9ae4810cbd1f8c9 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 14 May 2025 00:46:13 -0400 Subject: Move derivation of the synthesized view of channels (and messages) into `session`. --- ui/lib/session.svelte.js | 52 +++++++++++++++++++++++++++++-- ui/lib/state/remote/channels.svelte.js | 5 ++- ui/lib/state/remote/messages.svelte.js | 7 +---- ui/routes/(app)/+layout.svelte | 26 ++-------------- ui/routes/(app)/ch/[channel]/+page.svelte | 2 +- 5 files changed, 55 insertions(+), 37 deletions(-) diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js index 21a391d..2dae3c4 100644 --- a/ui/lib/session.svelte.js +++ b/ui/lib/session.svelte.js @@ -6,16 +6,62 @@ 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); - channels = $derived(this.remote.channels.all); messages = $derived( - this.remote.messages.all.map((message) => - message.resolve({ sender: (id) => this.users.get(id) }) + 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) ) ); diff --git a/ui/lib/state/remote/channels.svelte.js b/ui/lib/state/remote/channels.svelte.js index b6da31b..8b190dd 100644 --- a/ui/lib/state/remote/channels.svelte.js +++ b/ui/lib/state/remote/channels.svelte.js @@ -1,5 +1,4 @@ import { DateTime } from 'luxon'; -import { SvelteMap } from 'svelte/reactivity'; class Channel { static boot({ at, id, name }) { @@ -18,10 +17,10 @@ class Channel { } export class Channels { - all = $state(); + all = $state([]); static boot(channels) { - const all = new SvelteMap(channels.map((channel) => [channel.id, Channel.boot(channel)])); + const all = channels.map((channel) => Channel.boot(channel)); return new Channels({ all }); } diff --git a/ui/lib/state/remote/messages.svelte.js b/ui/lib/state/remote/messages.svelte.js index c6d31f0..0a081bb 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'; -export class Message { +class Message { static boot({ id, at, channel, sender, body }) { return new Message({ id, @@ -21,11 +21,6 @@ export class Message { this.body = body; this.renderedBody = renderedBody; } - - resolve(get) { - const { sender, ...rest } = this; - return new Message({ sender: get.sender(sender), ...rest }); - } } export class Messages { diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index 90fa82e..c7e1f22 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -22,29 +22,7 @@ let pageContext = getContext('page'); let channel = $derived(page.params.channel); - let rawChannels = $derived(session.channels); - let rawChannelsMeta = $derived(session.local.all); - let rawMessages = $derived(session.messages); - - function enrichChannels(channels, channelsMeta, messages) { - const enrichedChannels = []; - for (const ch of channels.values()) { - const createdAt = ch.at; - const channelMessages = messages.filter((message) => message.channel === ch.id); - const sentAt = channelMessages.map((message) => message.at); - const lastEventAt = Math.max(createdAt, ...sentAt); - - const lastReadAt = channelsMeta.get(ch.id)?.lastReadAt; - const hasUnreads = lastReadAt === undefined || lastEventAt > lastReadAt; - enrichedChannels.push({ - ...ch, - hasUnreads - }); - } - return enrichedChannels; - } - - const enrichedChannels = $derived(enrichChannels(rawChannels, rawChannelsMeta, rawMessages)); + let channels = $derived(session.channels); function setUpGestures() { if (!browser) { @@ -136,7 +114,7 @@