From 8edd5625ad5dde0ef1637d5c89e9901b3ee65d73 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 5 Oct 2024 20:12:25 -0400 Subject: Provide named operations for stored state. --- hi-ui/src/apiServer.js | 37 +++--------------- hi-ui/src/lib/ActiveChannel.svelte | 12 ++---- hi-ui/src/lib/Channel.svelte | 4 +- hi-ui/src/lib/ChannelList.svelte | 4 +- hi-ui/src/lib/CreateChannelForm.svelte | 2 +- hi-ui/src/lib/MessageInput.svelte | 4 +- hi-ui/src/store.js | 8 ++-- hi-ui/src/store/channels.js | 71 ++++++++++++++++++++++++++++++++++ hi-ui/src/store/messages.js | 35 +++++++++++++++++ 9 files changed, 127 insertions(+), 50 deletions(-) create mode 100644 hi-ui/src/store/channels.js create mode 100644 hi-ui/src/store/messages.js diff --git a/hi-ui/src/apiServer.js b/hi-ui/src/apiServer.js index 5e521de..6273576 100644 --- a/hi-ui/src/apiServer.js +++ b/hi-ui/src/apiServer.js @@ -1,5 +1,5 @@ import axios from 'axios'; -import { activeChannel, channelsList, events } from './store'; +import { activeChannel, channelsList, messages } from './store'; export const apiServer = axios.create({ baseURL: '/api/', @@ -39,7 +39,6 @@ export async function deleteMessage(messageId) { export function subscribeToEvents() { const evtSource = new EventSource("/api/events"); - events.update(() => []); // TODO: this should process all incoming events and store them. // TODO: eventually we'll need to handle expiring old info, so as not to use // infinite browser memory. @@ -61,39 +60,15 @@ export function subscribeToEvents() { case 'created': break; case 'message': - events.update((value) => { - const eventList = [...value, data]; - eventList.sort((a, b) => a.at - b.at); - return eventList; - }); + messages.update((value) => value.addMessage(data)); break; case 'message_deleted': - events.update((value) => { - const eventList = value.map((el) => { - if (el.message?.id === data.message) { - el.message.body = '«…»'; - return el - } else { - return el; - } - }); - return eventList; - }); + messages.update((value) => value.deleteMessage(data.channel.id, data.message)); break; case 'deleted': - activeChannel.update((value) => { - if (value?.id === data.channel) { - return null; - } - return value; - }); - channelsList.update((value) => { - const channelIndex = value.map((e) => e.id).indexOf(data.channel); - if (channelIndex !== -1) { - value.splice(channelIndex, 1); - } - return value; - }); + activeChannel.update((value) => value.deleteChannel(data.channel)); + channelsList.update((value) => value.deleteChannel(data.channel)); + messages.update((value) => value.deleteChannel(data.channel)); break; default: break; diff --git a/hi-ui/src/lib/ActiveChannel.svelte b/hi-ui/src/lib/ActiveChannel.svelte index 84f9119..d2d92fb 100644 --- a/hi-ui/src/lib/ActiveChannel.svelte +++ b/hi-ui/src/lib/ActiveChannel.svelte @@ -1,15 +1,9 @@
- {#each messages as message} + {#each messageList as message}
diff --git a/hi-ui/src/lib/Channel.svelte b/hi-ui/src/lib/Channel.svelte index 7826c46..ad07594 100644 --- a/hi-ui/src/lib/Channel.svelte +++ b/hi-ui/src/lib/Channel.svelte @@ -6,11 +6,11 @@ let active = false; activeChannel.subscribe((value) => { - active = value ? value.id == id : false; + active = value.is(id); }); function activate() { - activeChannel.update(() => ({ id, name })); + activeChannel.update((value) => value.set(id)); } diff --git a/hi-ui/src/lib/ChannelList.svelte b/hi-ui/src/lib/ChannelList.svelte index 9f88e24..5577d94 100644 --- a/hi-ui/src/lib/ChannelList.svelte +++ b/hi-ui/src/lib/ChannelList.svelte @@ -9,12 +9,12 @@ let loading = true; channelsList.subscribe((value) => { - channels = value; + channels = value.channels; }); onMount(async () => { let channels = await listChannels(); - channelsList.update(() => channels.data); + channelsList.update((value) => value.setChannels(channels.data)); loading = false; }); diff --git a/hi-ui/src/lib/CreateChannelForm.svelte b/hi-ui/src/lib/CreateChannelForm.svelte index 70dc13d..aa415fd 100644 --- a/hi-ui/src/lib/CreateChannelForm.svelte +++ b/hi-ui/src/lib/CreateChannelForm.svelte @@ -10,7 +10,7 @@ disabled = true; const response = await createChannel(name); if (200 <= response.status && response.status < 300) { - channelsList.update((value) => [...value, response.data]); + channelsList.update((value) => value.addChannel(response.data)); name = ''; } disabled = false; diff --git a/hi-ui/src/lib/MessageInput.svelte b/hi-ui/src/lib/MessageInput.svelte index 9a8475c..b899221 100644 --- a/hi-ui/src/lib/MessageInput.svelte +++ b/hi-ui/src/lib/MessageInput.svelte @@ -8,12 +8,12 @@ let self; let input; - $: disabled = $activeChannel == null; + $: disabled = !$activeChannel.isSet(); async function handleSubmit(event) { disabled = true; // TODO try/catch: - await postToChannel($activeChannel?.id, input); + await postToChannel($activeChannel.get(), input); input = ''; disabled = false; await tick(); diff --git a/hi-ui/src/store.js b/hi-ui/src/store.js index a9d9421..4e6b4f1 100644 --- a/hi-ui/src/store.js +++ b/hi-ui/src/store.js @@ -1,6 +1,8 @@ import { writable } from 'svelte/store'; +import { ActiveChannel, Channels } from './store/channels'; +import { Messages } from './store/messages'; export const currentUser = writable(null); -export const activeChannel = writable(null); -export const channelsList = writable([]); -export const events = writable([]); +export const activeChannel = writable(new ActiveChannel()); +export const channelsList = writable(new Channels()); +export const messages = writable(new Messages()); diff --git a/hi-ui/src/store/channels.js b/hi-ui/src/store/channels.js new file mode 100644 index 0000000..20702cc --- /dev/null +++ b/hi-ui/src/store/channels.js @@ -0,0 +1,71 @@ +export class Channels { + constructor() { + this.channels = []; + } + + setChannels(channels) { + this.channels = [...channels]; + this.sort(); + return this; + } + + addChannel(channel) { + this.channels = [...this.channels, channel]; + this.sort(); + return this; + } + + deleteChannel(id) { + const channelIndex = this.channels.map((e) => e.id).indexOf(id); + if (channelIndex !== -1) { + this.channels.splice(channelIndex, 1); + } + return this; + } + + sort() { + this.channels.sort((a, b) => { + if (a.name < b.name) { + return -1; + } else if (a.name > b.name) { + return 1; + } + return 0; + }); + } +} + +export class ActiveChannel { + constructor() { + this.channel = null; + } + + isSet() { + return this.channel !== null; + } + + get() { + return this.channel; + } + + is(id) { + return this.channel === id; + } + + set(id) { + this.channel = id; + return this; + } + + deleteChannel(id) { + if (this.is(id)) { + return this.clear(); + } + return this; + } + + clear() { + this.channel = null; + return this; + } +} diff --git a/hi-ui/src/store/messages.js b/hi-ui/src/store/messages.js new file mode 100644 index 0000000..d1f19d3 --- /dev/null +++ b/hi-ui/src/store/messages.js @@ -0,0 +1,35 @@ +export class Messages { + constructor() { + this.channels = {}; + } + + inChannel(channel) { + return this.channels[channel] || []; + } + + addMessage(message) { + let { + channel, + ...payload + } = message; + let channel_id = channel.id; + this.updateChannel(channel_id, (messages) => [...messages, payload]); + return this; + } + + deleteMessage(channel, message) { + let messages = this.messages(channel).filter((msg) => msg.message.id != message); + this.channels[channel] = messages; + } + + deleteChannel(id) { + delete this.channels[id]; + return this; + } + + updateChannel(channel, callback) { + let messages = callback(this.inChannel(channel)); + messages.sort((a, b) => a.at - b.at); + this.channels[channel] = messages; + } +} -- cgit v1.2.3 From 1fb26ad31d385ddc628e1b73d6a8764981ca6885 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 5 Oct 2024 20:32:02 -0400 Subject: Use `/api/boot` to bootstrap the client. The client now takes an initial snapshot from the response to `/api/boot`, then picks up the event stream at the immediately-successive event to the moment the snapshot was taken. This commit removes the following unused endpoints: * `/api/channels` (GET) * `/api/channels/:channel/messages` (GET) The information therein is now part of the boot response. We can always add 'em back, but I wanted to clear the deck for designing something more capable, for dealing with client needs. --- docs/api.md | 70 ++++++++------------------ hi-ui/src/apiServer.js | 11 ++--- hi-ui/src/lib/ChannelList.svelte | 20 ++------ hi-ui/src/lib/CreateChannelForm.svelte | 3 -- hi-ui/src/routes/+page.svelte | 25 +++++++--- hi-ui/src/store/messages.js | 6 +++ src/channel/routes.rs | 89 +--------------------------------- src/channel/routes/test/list.rs | 65 ------------------------- src/channel/routes/test/mod.rs | 1 - src/login/routes.rs | 65 +++++++++++++++++++++++++ src/message/app.rs | 27 +++++++++++ 11 files changed, 146 insertions(+), 236 deletions(-) delete mode 100644 src/channel/routes/test/list.rs diff --git a/docs/api.md b/docs/api.md index e8c8c8c..91485f3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -25,6 +25,25 @@ Returns information needed to boot the client. Also the recommended way to check "id": "L1234abcd", }, "resume_point": "1312", + "channels": [ + { + "name": "nonsense and such", + "id": "C1234abcd", + "messages": [ + { + "at": "2024-09-27T23:19:10.208147Z", + "sender": { + "id": "L1234abcd", + "name": "example username" + }, + "message": { + "id": "M1312acab", + "body": "beep" + } + } + ] + } + ] } ``` @@ -77,27 +96,6 @@ This endpoint returns a 204 No Content response on success, with a `Set-Cookie` Channels are the containers for conversations. The API supports listing channels, creating new channels, and send messages to an existing channel. -### `GET /api/channels` - -Lists channels. - -#### Query parameters - -This endpoint accepts an optional `resume_point` query parameter. If provided, the value must be the value obtained from the `/api/boot` method. This parameter will restrict the returned list to channels as they existed at a fixed point in time, with any later changes only appearing in the event stream. - -#### On success - -Responds with a list of channel objects, one per channel: - -```json -[ - { - "name": "nonsense and such", - "id": "C1234abcd", - } -] -``` - ### `POST /api/channels` Creates a channel. @@ -127,36 +125,6 @@ Channel names must be unique. If a channel with the same name already exists, th The API delivers events to clients to update them on other clients' actions and messages. While there is no specific delivery deadline, messages are delivered as soon as possible on a best-effort basis, and the event system allows clients to replay events or resume interrupted streams, to allow recovery if a message is lost. -### `GET /api/channels/:channel/messages` - -Retrieves historical messages in a channel. - -The `:channel` placeholder must be a channel ID, as returned by `GET /api/channels` or `POST /api/channels`. - -#### Query parameters - -This endpoint accepts an optional `resume_point` query parameter. If provided, the value must be the value obtained from the `/api/boot` method. This parameter will restrict the returned list to messages as they existed at a fixed point in time, with any later changes only appearing in the event stream. - -#### On success - -Responds with a list of message objects, one per message: - -```json -[ - { - "at": "2024-09-27T23:19:10.208147Z", - "sender": { - "id": "L1234abcd", - "name": "example username" - }, - "message": { - "id": "M1312acab", - "body": "beep" - } - } -] -``` - ### `POST /api/channels/:channel` Sends a chat message to a channel. It will be relayed to clients subscribed to the channel's events, and recorded for replay. diff --git a/hi-ui/src/apiServer.js b/hi-ui/src/apiServer.js index 6273576..f4a89a4 100644 --- a/hi-ui/src/apiServer.js +++ b/hi-ui/src/apiServer.js @@ -21,10 +21,6 @@ export async function logOut() { return apiServer.post('/auth/logout', {}); } -export async function listChannels() { - return apiServer.get('/channels'); -} - export async function createChannel(name) { return apiServer.post('/channels', { name }); } @@ -37,8 +33,10 @@ export async function deleteMessage(messageId) { // TODO } -export function subscribeToEvents() { - const evtSource = new EventSource("/api/events"); +export function subscribeToEvents(resume_point) { + const eventsUrl = new URL('/api/events', window.location); + eventsUrl.searchParams.append('resume_point', resume_point); + const evtSource = new EventSource(eventsUrl.toString()); // TODO: this should process all incoming events and store them. // TODO: eventually we'll need to handle expiring old info, so as not to use // infinite browser memory. @@ -58,6 +56,7 @@ export function subscribeToEvents() { switch (data.type) { case 'created': + channelsList.update((value) => value.addChannel(data.channel)) break; case 'message': messages.update((value) => value.addMessage(data)); diff --git a/hi-ui/src/lib/ChannelList.svelte b/hi-ui/src/lib/ChannelList.svelte index 5577d94..ba48e5d 100644 --- a/hi-ui/src/lib/ChannelList.svelte +++ b/hi-ui/src/lib/ChannelList.svelte @@ -1,32 +1,18 @@
    - {#if loading} -
  • loading channels…
  • - {:else} - {#each channels as channel} - - {/each} - {/if} + {#each channels as channel} + + {/each}