diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2025-07-01 15:40:11 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2025-07-03 22:43:44 -0400 |
| commit | 9b38cb1a62ede4900fde4ba47a7b065db329e994 (patch) | |
| tree | abf0b9d993ef03a53903aae03f375b78473952da /ui/lib | |
| parent | 1cafeb5ec92c1dc4ad74fbed58b15a8ab2f3c0cf (diff) | |
Rename "channel" to "conversation" throughout the client.
Existing client state, stored in local storage, is migrated to new keys (that mention "conversation" instead of "channel" where appropriate) the first time the client loads.
Diffstat (limited to 'ui/lib')
| -rw-r--r-- | ui/lib/apiServer.js | 7 | ||||
| -rw-r--r-- | ui/lib/components/ChannelList.svelte | 13 | ||||
| -rw-r--r-- | ui/lib/components/Conversation.svelte (renamed from ui/lib/components/Channel.svelte) | 0 | ||||
| -rw-r--r-- | ui/lib/components/ConversationList.svelte | 17 | ||||
| -rw-r--r-- | ui/lib/components/CreateConversationForm.svelte (renamed from ui/lib/components/CreateChannelForm.svelte) | 6 | ||||
| -rw-r--r-- | ui/lib/outbox.svelte.js | 24 | ||||
| -rw-r--r-- | ui/lib/session.svelte.js | 16 | ||||
| -rw-r--r-- | ui/lib/state/local/channels.svelte.js | 118 | ||||
| -rw-r--r-- | ui/lib/state/local/conversations.svelte.js | 119 | ||||
| -rw-r--r-- | ui/lib/state/remote/conversations.svelte.js (renamed from ui/lib/state/remote/channels.svelte.js) | 10 | ||||
| -rw-r--r-- | ui/lib/state/remote/state.svelte.js | 8 |
11 files changed, 171 insertions, 167 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index 1bca6f6..ac707a5 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -1,7 +1,6 @@ import axios from 'axios'; import * as r from './retry.js'; -import { timedDelay } from './retry.js'; export const apiServer = axios.create({ baseURL: '/api/', @@ -27,12 +26,12 @@ export async function changePassword(password, to) { return await apiServer.post('/password', { password, to }).catch(responseError); } -export async function createChannel(name) { +export async function createConversation(name) { return await apiServer.post('/conversations', { name }).catch(responseError); } -export async function postToChannel(channelId, body) { - return await apiServer.post(`/conversations/${channelId}`, { body }).catch(responseError); +export async function sendToConversation(conversationId, body) { + return await apiServer.post(`/conversations/${conversationId}`, { body }).catch(responseError); } export async function deleteMessage(messageId) { diff --git a/ui/lib/components/ChannelList.svelte b/ui/lib/components/ChannelList.svelte deleted file mode 100644 index 51dd6cf..0000000 --- a/ui/lib/components/ChannelList.svelte +++ /dev/null @@ -1,13 +0,0 @@ -<script> - import Channel from './Channel.svelte'; - - let { channels, active } = $props(); -</script> - -<nav class="list-nav"> - <ul> - {#each channels as channel} - <Channel {...channel} active={active === channel.id} /> - {/each} - </ul> -</nav> diff --git a/ui/lib/components/Channel.svelte b/ui/lib/components/Conversation.svelte index 9004e50..9004e50 100644 --- a/ui/lib/components/Channel.svelte +++ b/ui/lib/components/Conversation.svelte diff --git a/ui/lib/components/ConversationList.svelte b/ui/lib/components/ConversationList.svelte new file mode 100644 index 0000000..71332e0 --- /dev/null +++ b/ui/lib/components/ConversationList.svelte @@ -0,0 +1,17 @@ +<script> + import Conversation from './Conversation.svelte'; + + let { conversations, active } = $props(); + + function isActive(conversation) { + return active === conversation.id; + } +</script> + +<nav class="list-nav"> + <ul> + {#each conversations as conversation} + <Conversation {...conversation} active={isActive(conversation)} /> + {/each} + </ul> +</nav> diff --git a/ui/lib/components/CreateChannelForm.svelte b/ui/lib/components/CreateConversationForm.svelte index 471c2b7..e390a78 100644 --- a/ui/lib/components/CreateChannelForm.svelte +++ b/ui/lib/components/CreateConversationForm.svelte @@ -1,5 +1,5 @@ <script> - let { createChannel = async (name) => {} } = $props(); + let { createConversation = async (name) => {} } = $props(); let name = $state(''); let disabled = $state(false); @@ -8,7 +8,7 @@ event.preventDefault(); disabled = true; try { - await createChannel(name); + await createConversation(name); event.target.reset(); } finally { disabled = false; @@ -17,6 +17,6 @@ </script> <form {onsubmit}> - <input type="text" placeholder="create channel" bind:value={name} {disabled} /> + <input type="text" placeholder="start a conversation" bind:value={name} {disabled} /> <button type="submit">➕</button> </form> diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js index 183f8ff..c4e2324 100644 --- a/ui/lib/outbox.svelte.js +++ b/ui/lib/outbox.svelte.js @@ -4,9 +4,9 @@ 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; +class SendToConversation { + constructor(conversation, body) { + this.conversation = conversation; this.body = body; this.at = DateTime.now(); this.renderedBody = md.render(body); @@ -16,7 +16,7 @@ class PostToChannel { return { id: null, at: this.at, - channel: this.channel, + conversation: this.conversation, sender, body: this.body, renderedBody: this.renderedBody, @@ -24,7 +24,7 @@ class PostToChannel { } async send() { - return await api.retry(() => api.postToChannel(this.channel, this.body)); + return await api.retry(() => api.sendToConversation(this.conversation, this.body)); } } @@ -38,19 +38,19 @@ class DeleteMessage { } } -class CreateChannel { +class CreateConversation { constructor(name) { this.name = name; } async send() { - return await api.retry(() => api.createChannel(this.name)); + return await api.retry(() => api.createConversation(this.name)); } } export class Outbox { pending = $state([]); - messages = $derived(this.pending.filter((operation) => operation instanceof PostToChannel)); + messages = $derived(this.pending.filter((operation) => operation instanceof SendToConversation)); deleted = $derived(this.pending.filter((operation) => operation instanceof DeleteMessage)); static empty() { @@ -66,12 +66,12 @@ export class Outbox { this.start(); } - createChannel(name) { - this.enqueue(new CreateChannel(name)); + createConversation(name) { + this.enqueue(new CreateConversation(name)); } - postToChannel(channel, body) { - this.enqueue(new PostToChannel(channel, body)); + sendToConversation(conversationId, body) { + this.enqueue(new SendToConversation(conversationId, body)); } deleteMessage(messageId) { diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js index 0c73e00..4430e8a 100644 --- a/ui/lib/session.svelte.js +++ b/ui/lib/session.svelte.js @@ -4,11 +4,11 @@ import { goto } from '$app/navigation'; 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 * as l from './state/local/conversations.svelte.js'; import { Watchdog } from './watchdog.js'; import { DateTime } from 'luxon'; -class Channel { +class Conversation { static fromRemote({ at, id, name }, messages, meta) { const sentAt = messages .filter((message) => message.conversation === id) @@ -17,7 +17,7 @@ class Channel { const lastReadAt = meta.get(id)?.lastReadAt; const hasUnreads = lastReadAt === undefined || lastEventAt > lastReadAt; - return new Channel({ at, id, name, hasUnreads }); + return new Conversation({ at, id, name, hasUnreads }); } constructor({ at, id, name, hasUnreads }) { @@ -58,9 +58,9 @@ class Session { messages = $derived( 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), + conversations = $derived( + this.remote.conversations.all.map((conversation) => + Conversation.fromRemote(conversation, this.messages, this.local.all), ), ); @@ -71,7 +71,7 @@ class Session { heartbeat, events, }); - const local = l.Channels.fromLocalStorage(); + const local = l.Conversations.fromLocalStorage(); return new Session(remote, local); } @@ -109,7 +109,7 @@ class Session { onMessage(message) { const event = JSON.parse(message.data); this.remote.onEvent(event); - this.local.retainChannels(this.remote.channels.all); + this.local.retainConversations(this.remote.conversations.all); this.watchdog.reset(this.heartbeatMillis()); } diff --git a/ui/lib/state/local/channels.svelte.js b/ui/lib/state/local/channels.svelte.js deleted file mode 100644 index 669aa1e..0000000 --- a/ui/lib/state/local/channels.svelte.js +++ /dev/null @@ -1,118 +0,0 @@ -import { DateTime } from 'luxon'; -import { SvelteMap } from 'svelte/reactivity'; - -import * as iter from '$lib/iterator.js'; - -export const STORE_KEY_CHANNELS_DATA = 'pilcrow:channelsData'; - -class Channel { - draft = $state(); - lastReadAt = $state(null); - scrollPosition = $state(null); - - static fromStored({ draft, lastReadAt, scrollPosition }) { - return new Channel({ - draft, - lastReadAt: lastReadAt == null ? null : DateTime.fromISO(lastReadAt), - scrollPosition, - }); - } - - constructor({ draft = '', lastReadAt = null, scrollPosition = null } = {}) { - this.draft = draft; - this.lastReadAt = lastReadAt; - this.scrollPosition = scrollPosition; - } - - toStored() { - const { draft, lastReadAt, scrollPosition } = this; - return { - draft, - lastReadAt: lastReadAt?.toISO(), - scrollPosition, - }; - } -} - -export class Channels { - // Store channelId -> { draft = '', lastReadAt = null, scrollPosition = null } - all = $state(); - - static fromLocalStorage() { - const stored = localStorage.getItem(STORE_KEY_CHANNELS_DATA); - if (stored !== null) { - return Channels.fromStored(JSON.parse(stored)); - } - return Channels.empty(); - } - - static fromStored(stored) { - const loaded = Object.keys(stored).map((channelId) => [ - channelId, - Channel.fromStored(stored[channelId]), - ]); - const all = new SvelteMap(loaded); - return new Channels({ all }); - } - - static empty() { - return new Channels({ all: new SvelteMap() }); - } - - constructor({ all }) { - this.all = all; - } - - channel(channelId) { - let channel = this.all.get(channelId); - if (channel === undefined) { - channel = new Channel(); - this.all.set(channelId, channel); - } - return channel; - } - - updateLastReadAt(channelId, at) { - const channel = this.channel(channelId); - // Do it this way, rather than with Math.max tricks, to avoid assignment - // when we don't need it, to minimize reactive changes: - if (channel.lastReadAt === null || at > channel.lastReadAt) { - channel.lastReadAt = at; - this.save(); - } - } - - retainChannels(channels) { - const channelIds = channels.map((channel) => channel.id); - const retain = new Set(channelIds); - for (const channelId of Array.from(this.all.keys())) { - if (!retain.has(channelId)) { - this.all.delete(channelId); - } - } - this.save(); - } - - toStored() { - return iter.reduce( - this.all.entries(), - (stored, [channelId, channel]) => ({ - ...stored, - [channelId]: channel.toStored(), - }), - {}, - ); - } - - save() { - let stored = this.toStored(); - localStorage.setItem(STORE_KEY_CHANNELS_DATA, JSON.stringify(stored)); - } -} - -function objectMap(object, mapFn) { - return Object.keys(object).reduce((result, key) => { - result[key] = mapFn(object[key]); - return result; - }, {}); -} diff --git a/ui/lib/state/local/conversations.svelte.js b/ui/lib/state/local/conversations.svelte.js new file mode 100644 index 0000000..835c237 --- /dev/null +++ b/ui/lib/state/local/conversations.svelte.js @@ -0,0 +1,119 @@ +import { DateTime } from 'luxon'; +import { SvelteMap } from 'svelte/reactivity'; + +import * as iter from '$lib/iterator.js'; + +// Conversations were called "channels" in previous iterations. Support loading +// data saved under that name to prevent the change from resetting everyone's +// unread tracking. +export const STORE_KEY_CHANNELS = 'pilcrow:channelsData'; +export const STORE_KEY_CONVERSATIONS = 'pilcrow:conversations'; + +class Conversation { + draft = $state(); + lastReadAt = $state(null); + scrollPosition = $state(null); + + static fromStored({ draft, lastReadAt, scrollPosition }) { + return new Conversation({ + draft, + lastReadAt: lastReadAt == null ? null : DateTime.fromISO(lastReadAt), + scrollPosition, + }); + } + + constructor({ draft = '', lastReadAt = null, scrollPosition = null } = {}) { + this.draft = draft; + this.lastReadAt = lastReadAt; + this.scrollPosition = scrollPosition; + } + + toStored() { + const { draft, lastReadAt, scrollPosition } = this; + return { + draft, + lastReadAt: lastReadAt?.toISO(), + scrollPosition, + }; + } +} + +export class Conversations { + // Store conversationId -> { draft = '', lastReadAt = null, scrollPosition = null } + all = $state(); + + static fromLocalStorage() { + const stored = + localStorage.getItem(STORE_KEY_CONVERSATIONS) ?? localStorage.getItem(STORE_KEY_CHANNELS); + if (stored !== null) { + return Conversations.fromStored(JSON.parse(stored)); + } + return Conversations.empty(); + } + + static fromStored(stored) { + const loaded = Object.keys(stored).map((conversationId) => [ + conversationId, + Conversation.fromStored(stored[conversationId]), + ]); + const all = new SvelteMap(loaded); + return new Conversations({ all }); + } + + static empty() { + return new Conversations({ all: new SvelteMap() }); + } + + constructor({ all }) { + this.all = all; + } + + conversation(conversationId) { + let conversation = this.all.get(conversationId); + if (conversation === undefined) { + conversation = new Conversation(); + this.all.set(conversationId, conversation); + } + return conversation; + } + + updateLastReadAt(conversationId, at) { + const conversation = this.conversation(conversationId); + // Do it this way, rather than with Math.max tricks, to avoid assignment + // when we don't need it, to minimize reactive changes: + if (conversation.lastReadAt === null || at > conversation.lastReadAt) { + conversation.lastReadAt = at; + this.save(); + } + } + + retainConversations(conversations) { + const conversationIds = conversations.map((conversation) => conversation.id); + const retain = new Set(conversationIds); + for (const conversationId of Array.from(this.all.keys())) { + if (!retain.has(conversationId)) { + this.all.delete(conversationId); + } + } + this.save(); + } + + toStored() { + return iter.reduce( + this.all.entries(), + (stored, [conversationId, conversation]) => ({ + ...stored, + [conversationId]: conversation.toStored(), + }), + {}, + ); + } + + save() { + let stored = this.toStored(); + localStorage.setItem(STORE_KEY_CONVERSATIONS, JSON.stringify(stored)); + // If we were able to save the data under `pilcrow:conversations`, then remove the old data; + // it is no longer needed and wouldn't be loaded anyways. + localStorage.removeItem(STORE_KEY_CHANNELS); + } +} diff --git a/ui/lib/state/remote/channels.svelte.js b/ui/lib/state/remote/conversations.svelte.js index 1e40075..79868f4 100644 --- a/ui/lib/state/remote/channels.svelte.js +++ b/ui/lib/state/remote/conversations.svelte.js @@ -1,8 +1,8 @@ import { DateTime } from 'luxon'; -class Channel { +class Conversation { static boot({ at, id, name }) { - return new Channel({ + return new Conversation({ at: DateTime.fromISO(at), id, name, @@ -16,14 +16,14 @@ class Channel { } } -export class Channels { +export class Conversations { all = $state([]); add({ at, id, name }) { - this.all.push(Channel.boot({ at, id, name })); + this.all.push(Conversation.boot({ at, id, name })); } remove(id) { - this.all = this.all.filter((channel) => channel.id !== id); + this.all = this.all.filter((conversation) => conversation.id !== id); } } diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js index ffc88c6..3d65e4a 100644 --- a/ui/lib/state/remote/state.svelte.js +++ b/ui/lib/state/remote/state.svelte.js @@ -1,11 +1,11 @@ import { User, Users } from './users.svelte.js'; -import { Channels } from './channels.svelte.js'; +import { Conversations } from './conversations.svelte.js'; import { Messages } from './messages.svelte.js'; export class State { currentUser = $state(); users = $state(new Users()); - channels = $state(new Channels()); + conversations = $state(new Conversations()); messages = $state(new Messages()); static boot({ currentUser, heartbeat, resumePoint, events }) { @@ -50,12 +50,12 @@ export class State { onConversationCreated(event) { const { id, name } = event; - this.channels.add({ id, name }); + this.conversations.add({ id, name }); } onConversationDeleted(event) { const { id } = event; - this.channels.remove(id); + this.conversations.remove(id); } onUserEvent(event) { |
