From 1cafeb5ec92c1dc4ad74fbed58b15a8ab2f3c0cf Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 1 Jul 2025 15:30:44 -0400
Subject: Move the `/ch` channel view to `/c` (for conversation).

---
 ui/routes/(app)/+layout.svelte | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

(limited to 'ui/routes/(app)/+layout.svelte')

diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte
index c7e1f22..e3272bc 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -20,7 +20,7 @@
   onDestroy(session.end.bind(session));
 
   let pageContext = getContext('page');
-  let channel = $derived(page.params.channel);
+  let channel = $derived(page.params.conversation);
 
   let channels = $derived(session.channels);
 
@@ -60,7 +60,7 @@
     const lastActiveChannel = getLastActiveChannel();
     const inRoot = page.url.pathname === '/';
     if (inRoot && lastActiveChannel) {
-      goto(`/ch/${lastActiveChannel}`);
+      goto(`/c/${lastActiveChannel}`);
     } else if (channel) {
       setLastActiveChannel(channel || null);
     }
-- 
cgit v1.2.3


From 9b38cb1a62ede4900fde4ba47a7b065db329e994 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 1 Jul 2025 15:40:11 -0400
Subject: 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.
---
 ui/app.css                                         |   6 +-
 ui/lib/apiServer.js                                |   7 +-
 ui/lib/components/Channel.svelte                   |  14 ---
 ui/lib/components/ChannelList.svelte               |  13 ---
 ui/lib/components/Conversation.svelte              |  14 +++
 ui/lib/components/ConversationList.svelte          |  17 +++
 ui/lib/components/CreateChannelForm.svelte         |  22 ----
 ui/lib/components/CreateConversationForm.svelte    |  22 ++++
 ui/lib/outbox.svelte.js                            |  24 ++---
 ui/lib/session.svelte.js                           |  16 +--
 ui/lib/state/local/channels.svelte.js              | 118 --------------------
 ui/lib/state/local/conversations.svelte.js         | 119 +++++++++++++++++++++
 ui/lib/state/remote/channels.svelte.js             |  29 -----
 ui/lib/state/remote/conversations.svelte.js        |  29 +++++
 ui/lib/state/remote/state.svelte.js                |   8 +-
 ui/routes/(app)/+layout.svelte                     |  46 ++++----
 ui/routes/(app)/+page.svelte                       |   4 +-
 ui/routes/(app)/c/[conversation]/+page.svelte      |  28 ++---
 ui/styles/active-channel.css                       |   6 --
 ui/styles/active-conversation.css                  |   6 ++
 ui/styles/overscroll.css                           |  11 +-
 ui/styles/sidebar.css                              |  12 +--
 ui/styles/variables.css                            |   4 +-
 .../components/CreateChannelForm.svelte.test.js    |  18 ++--
 .../lib/components/MessageInput.svelte.test.js     |   2 +-
 25 files changed, 305 insertions(+), 290 deletions(-)
 delete mode 100644 ui/lib/components/Channel.svelte
 delete mode 100644 ui/lib/components/ChannelList.svelte
 create mode 100644 ui/lib/components/Conversation.svelte
 create mode 100644 ui/lib/components/ConversationList.svelte
 delete mode 100644 ui/lib/components/CreateChannelForm.svelte
 create mode 100644 ui/lib/components/CreateConversationForm.svelte
 delete mode 100644 ui/lib/state/local/channels.svelte.js
 create mode 100644 ui/lib/state/local/conversations.svelte.js
 delete mode 100644 ui/lib/state/remote/channels.svelte.js
 create mode 100644 ui/lib/state/remote/conversations.svelte.js
 delete mode 100644 ui/styles/active-channel.css
 create mode 100644 ui/styles/active-conversation.css

(limited to 'ui/routes/(app)/+layout.svelte')

diff --git a/ui/app.css b/ui/app.css
index 79f36eb..34a74b7 100644
--- a/ui/app.css
+++ b/ui/app.css
@@ -5,14 +5,14 @@
 @import url('styles/app-bar.css');
 @import url('styles/app-layout.css');
 @import url('styles/sidebar.css');
-@import url('styles/active-channel.css');
+@import url('styles/active-conversation.css');
 @import url('styles/messages.css');
 @import url('styles/textarea.css');
 @import url('styles/forms.css');
 @import url('styles/invites.css');
 
 body {
-  background-color: var(--colour-active-channel-bg);
+  background-color: var(--colour-active-conversation-bg);
   color: var(--dark-text);
   font-family: 'Roboto', sans-serif;
 }
@@ -21,7 +21,7 @@ hr {
   width: 90%;
 }
 
-.no-active-channel {
+.no-active-conversation {
   display: table;
   height: 100%;
   width: 100%;
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/Channel.svelte b/ui/lib/components/Channel.svelte
deleted file mode 100644
index 9004e50..0000000
--- a/ui/lib/components/Channel.svelte
+++ /dev/null
@@ -1,14 +0,0 @@
-<script>
-  let { id, name, active, hasUnreads } = $props();
-</script>
-
-<li class:active>
-  <a href="/c/{id}">
-    {#if hasUnreads}
-      <span class="badge has-unreads">❦</span>
-    {:else}
-      <span class="badge has-no-unreads">¶</span>
-    {/if}
-    <span>{name}</span>
-  </a>
-</li>
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/Conversation.svelte b/ui/lib/components/Conversation.svelte
new file mode 100644
index 0000000..9004e50
--- /dev/null
+++ b/ui/lib/components/Conversation.svelte
@@ -0,0 +1,14 @@
+<script>
+  let { id, name, active, hasUnreads } = $props();
+</script>
+
+<li class:active>
+  <a href="/c/{id}">
+    {#if hasUnreads}
+      <span class="badge has-unreads">❦</span>
+    {:else}
+      <span class="badge has-no-unreads">¶</span>
+    {/if}
+    <span>{name}</span>
+  </a>
+</li>
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/CreateChannelForm.svelte
deleted file mode 100644
index 471c2b7..0000000
--- a/ui/lib/components/CreateChannelForm.svelte
+++ /dev/null
@@ -1,22 +0,0 @@
-<script>
-  let { createChannel = async (name) => {} } = $props();
-
-  let name = $state('');
-  let disabled = $state(false);
-
-  async function onsubmit(event) {
-    event.preventDefault();
-    disabled = true;
-    try {
-      await createChannel(name);
-      event.target.reset();
-    } finally {
-      disabled = false;
-    }
-  }
-</script>
-
-<form {onsubmit}>
-  <input type="text" placeholder="create channel" bind:value={name} {disabled} />
-  <button type="submit">&#x2795;</button>
-</form>
diff --git a/ui/lib/components/CreateConversationForm.svelte b/ui/lib/components/CreateConversationForm.svelte
new file mode 100644
index 0000000..e390a78
--- /dev/null
+++ b/ui/lib/components/CreateConversationForm.svelte
@@ -0,0 +1,22 @@
+<script>
+  let { createConversation = async (name) => {} } = $props();
+
+  let name = $state('');
+  let disabled = $state(false);
+
+  async function onsubmit(event) {
+    event.preventDefault();
+    disabled = true;
+    try {
+      await createConversation(name);
+      event.target.reset();
+    } finally {
+      disabled = false;
+    }
+  }
+</script>
+
+<form {onsubmit}>
+  <input type="text" placeholder="start a conversation" bind:value={name} {disabled} />
+  <button type="submit">&#x2795;</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/channels.svelte.js
deleted file mode 100644
index 1e40075..0000000
--- a/ui/lib/state/remote/channels.svelte.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { DateTime } from 'luxon';
-
-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([]);
-
-  add({ at, id, name }) {
-    this.all.push(Channel.boot({ at, id, name }));
-  }
-
-  remove(id) {
-    this.all = this.all.filter((channel) => channel.id !== id);
-  }
-}
diff --git a/ui/lib/state/remote/conversations.svelte.js b/ui/lib/state/remote/conversations.svelte.js
new file mode 100644
index 0000000..79868f4
--- /dev/null
+++ b/ui/lib/state/remote/conversations.svelte.js
@@ -0,0 +1,29 @@
+import { DateTime } from 'luxon';
+
+class Conversation {
+  static boot({ at, id, name }) {
+    return new Conversation({
+      at: DateTime.fromISO(at),
+      id,
+      name,
+    });
+  }
+
+  constructor({ at, id, name }) {
+    this.at = at;
+    this.id = id;
+    this.name = name;
+  }
+}
+
+export class Conversations {
+  all = $state([]);
+
+  add({ at, id, name }) {
+    this.all.push(Conversation.boot({ at, id, name }));
+  }
+
+  remove(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) {
diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte
index e3272bc..658d966 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -7,9 +7,8 @@
 
   import TinyGesture from 'tinygesture';
 
-  import * as api from '$lib/apiServer.js';
-  import ChannelList from '$lib/components/ChannelList.svelte';
-  import CreateChannelForm from '$lib/components/CreateChannelForm.svelte';
+  import ConversationList from '$lib/components/ConversationList.svelte';
+  import CreateConversationForm from '$lib/components/CreateConversationForm.svelte';
 
   let gesture = null;
 
@@ -20,9 +19,9 @@
   onDestroy(session.end.bind(session));
 
   let pageContext = getContext('page');
-  let channel = $derived(page.params.conversation);
+  let conversationId = $derived(page.params.conversation);
 
-  let channels = $derived(session.channels);
+  let conversations = $derived(session.conversations);
 
   function setUpGestures() {
     if (!browser) {
@@ -46,28 +45,35 @@
     }
   });
 
-  const STORE_KEY_LAST_ACTIVE = 'pilcrow:lastActiveChannel';
+  // Automatically migrate last-active-channel info now that we call them "conversations."
+  const STORE_KEY_LAST_ACTIVE = 'pilcrow:lastActiveConversation';
+  const STORE_KEY_LAST_ACTIVE_CHANNEL = 'pilcrow:lastActiveChannel';
 
-  function getLastActiveChannel() {
-    return browser && JSON.parse(localStorage.getItem(STORE_KEY_LAST_ACTIVE));
+  function getLastActiveConversation() {
+    const stored =
+      localStorage.getItem(STORE_KEY_LAST_ACTIVE) ??
+      localStorage.getItem(STORE_KEY_LAST_ACTIVE_CHANNEL);
+    return JSON.parse(stored);
   }
 
-  function setLastActiveChannel(channelId) {
-    browser && localStorage.setItem(STORE_KEY_LAST_ACTIVE, JSON.stringify(channelId));
+  function setLastActiveConversation(conversationId) {
+    localStorage.setItem(STORE_KEY_LAST_ACTIVE, JSON.stringify(conversationId));
+    // Once we've saved to the new key, we no longer need the old one. Clean it up.
+    localStorage.removeItem(STORE_KEY_LAST_ACTIVE_CHANNEL);
   }
 
   afterNavigate(() => {
-    const lastActiveChannel = getLastActiveChannel();
+    const conversationId = getLastActiveConversation();
     const inRoot = page.url.pathname === '/';
-    if (inRoot && lastActiveChannel) {
-      goto(`/c/${lastActiveChannel}`);
-    } else if (channel) {
-      setLastActiveChannel(channel || null);
+    if (inRoot && conversationId) {
+      goto(`/c/${conversationId}`);
+    } else if (conversationId) {
+      setLastActiveConversation(conversationId || null);
     }
   });
 
-  async function createChannel(name) {
-    outbox.createChannel(name);
+  async function createConversation(name) {
+    outbox.createConversation(name);
   }
 
   function onbeforeunload(event) {
@@ -114,9 +120,9 @@
 
 <div id="interface">
   <nav id="sidebar" data-expanded={pageContext.showMenu}>
-    <ChannelList active={channel} {channels} />
-    <div class="create-channel">
-      <CreateChannelForm {createChannel} />
+    <ConversationList active={conversationId} {conversations} />
+    <div class="create-conversation">
+      <CreateConversationForm {createConversation} />
     </div>
   </nav>
   <main>
diff --git a/ui/routes/(app)/+page.svelte b/ui/routes/(app)/+page.svelte
index 007c5c6..1db0eb2 100644
--- a/ui/routes/(app)/+page.svelte
+++ b/ui/routes/(app)/+page.svelte
@@ -1,3 +1,3 @@
-<div class="no-active-channel">
-  <span class="vertical-aligner"> Please select or create a channel. </span>
+<div class="no-active-conversation">
+  <span class="vertical-aligner">Please select or create a conversation.</span>
 </div>
diff --git a/ui/routes/(app)/c/[conversation]/+page.svelte b/ui/routes/(app)/c/[conversation]/+page.svelte
index 4d2cc86..e6cd845 100644
--- a/ui/routes/(app)/c/[conversation]/+page.svelte
+++ b/ui/routes/(app)/c/[conversation]/+page.svelte
@@ -8,14 +8,18 @@
 
   const { data } = $props();
   const { session, outbox } = data;
-  let activeChannel;
+  let activeConversation;
 
-  const channelId = $derived(page.params.conversation);
-  const channel = $derived(session.channels.find((channel) => channel.id === channelId));
+  const conversationId = $derived(page.params.conversation);
+  const conversation = $derived(
+    session.conversations.find((conversation) => conversation.id === conversationId),
+  );
   const messages = $derived(
-    session.messages.filter((message) => message.conversation === channelId),
+    session.messages.filter((message) => message.conversation === conversationId),
+  );
+  const unsent = $derived(
+    outbox.messages.filter((message) => message.conversation === conversationId),
   );
-  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))),
@@ -35,12 +39,12 @@
   }
 
   function getLastVisibleMessage() {
-    if (activeChannel) {
-      const childElements = activeChannel.getElementsByClassName('message');
+    if (activeConversation) {
+      const childElements = activeConversation.getElementsByClassName('message');
       const lastInView = Array.from(childElements)
         .reverse()
         .find((el) => {
-          return inView(activeChannel, el);
+          return inView(activeConversation, el);
         });
       return lastInView;
     }
@@ -48,9 +52,9 @@
 
   function setLastRead() {
     const lastInView = getLastVisibleMessage();
-    const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : channel?.at;
+    const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : conversation?.at;
     if (!!at) {
-      session.local.updateLastReadAt(channelId, at);
+      session.local.updateLastReadAt(conversationId, at);
     }
   }
 
@@ -79,7 +83,7 @@
   }
 
   async function sendMessage(message) {
-    outbox.postToChannel(channelId, message);
+    outbox.sendToConversation(conversationId, message);
   }
 
   async function deleteMessage(id) {
@@ -89,7 +93,7 @@
 
 <svelte:window onkeydown={handleKeydown} />
 
-<div class="active-channel" {onscroll} bind:this={activeChannel}>
+<div class="active-conversation" {onscroll} bind:this={activeConversation}>
   {#each messageRuns as { sender, ownMessage, messages }}
     <MessageRun
       {sender}
diff --git a/ui/styles/active-channel.css b/ui/styles/active-channel.css
deleted file mode 100644
index d6a9b42..0000000
--- a/ui/styles/active-channel.css
+++ /dev/null
@@ -1,6 +0,0 @@
-.active-channel {
-  padding-left: 1rem;
-  padding-right: 1rem;
-  overflow: auto;
-  flex-grow: 1;
-}
diff --git a/ui/styles/active-conversation.css b/ui/styles/active-conversation.css
new file mode 100644
index 0000000..981862b
--- /dev/null
+++ b/ui/styles/active-conversation.css
@@ -0,0 +1,6 @@
+.active-conversation {
+  padding-left: 1rem;
+  padding-right: 1rem;
+  overflow: auto;
+  flex-grow: 1;
+}
diff --git a/ui/styles/overscroll.css b/ui/styles/overscroll.css
index 8898f9a..a54235c 100644
--- a/ui/styles/overscroll.css
+++ b/ui/styles/overscroll.css
@@ -1,8 +1,9 @@
-/* This should help minimize swipe-to-go-back behaviour, enabling our
-* swipe-to-reveal-channel-menu behaviour. It won't work in all cases; in iOS
-* Safari, when swiping from the screen edge, the OS gets th event and
-* handles it before the browser does.
-*/
+/*
+ * This should help minimize swipe-to-go-back behaviour, enabling our
+ * swipe-to-reveal-conversation-menu behaviour. It won't work in all cases; in
+ * iOS Safari, when swiping from the screen edge, the OS gets the event and
+ * handles it before the browser does.
+ */
 html,
 body {
   overscroll-behavior-x: none;
diff --git a/ui/styles/sidebar.css b/ui/styles/sidebar.css
index b825545..aa0d53b 100644
--- a/ui/styles/sidebar.css
+++ b/ui/styles/sidebar.css
@@ -1,4 +1,4 @@
-/* Sidebar and channel selector */
+/* Sidebar and conversation selector */
 #sidebar {
   background-color: var(--colour-navbar-bg);
 }
@@ -38,19 +38,19 @@
   color: var(--colour-navbar-hover-text);
 }
 
-/* create channel form */
-.create-channel {
+/* create conversation form */
+.create-conversation {
   padding-left: 0.5rem;
 }
 
-.create-channel form {
+.create-conversation form {
   display: flex;
   flex-direction: row;
   justify-content: flex-start;
   align-items: stretch;
 }
 
-.create-channel input {
+.create-conversation input {
   padding: 0.5rem;
   border-radius: 0.5rem 0 0 0.5rem;
   border: 1px solid var(--colour-input-border);
@@ -60,7 +60,7 @@
   color: var(--colour-input-text);
 }
 
-.create-channel button {
+.create-conversation button {
   border-radius: 0 0.5rem 0.5rem 0;
   border: 1px solid var(--colour-input-border);
   background-color: var(--colour-input-bg);
diff --git a/ui/styles/variables.css b/ui/styles/variables.css
index 2758aa1..99705f2 100644
--- a/ui/styles/variables.css
+++ b/ui/styles/variables.css
@@ -40,8 +40,8 @@
   --colour-input-border: color-mix(in srgb, var(--colour-input-bg) 50%, black);
   --colour-input-text: var(--dark-text);
 
-  /* Active channel */
-  --colour-active-channel-bg: color-mix(in srgb, var(--colour-base) 25%, white);
+  /* Active conversation */
+  --colour-active-conversation-bg: color-mix(in srgb, var(--colour-base) 25%, white);
 
   /* MessageRun */
   --colour-message-run-self-bg: color-mix(in srgb, var(--colour-base) 30%, white);
diff --git a/ui/tests/lib/components/CreateChannelForm.svelte.test.js b/ui/tests/lib/components/CreateChannelForm.svelte.test.js
index 197cb6b..8c7b3fb 100644
--- a/ui/tests/lib/components/CreateChannelForm.svelte.test.js
+++ b/ui/tests/lib/components/CreateChannelForm.svelte.test.js
@@ -1,37 +1,37 @@
 import { render, screen } from '@testing-library/svelte';
 import userEvent from '@testing-library/user-event';
 import { beforeEach, expect, test, describe, it, vi } from 'vitest';
-import CreateChannelForm from '$lib/components/CreateChannelForm.svelte';
+import CreateConversationForm from '$lib/components/CreateConversationForm.svelte';
 
 const user = userEvent.setup();
 
 const mocks = vi.hoisted(() => ({
-  createChannel: vi.fn(),
+  createConversation: vi.fn(),
 }));
 
-describe('CreateChannelForm', async () => {
+describe('CreateConversationForm', async () => {
   beforeEach(async () => {
-    render(CreateChannelForm, {
-      createChannel: mocks.createChannel,
+    render(CreateConversationForm, {
+      createConversation: mocks.createConversation,
     });
   });
 
-  describe('creates channels', async () => {
+  describe('creates conversations', async () => {
     it('with a non-empty name', async () => {
       const input = screen.getByRole('textbox');
-      await user.type(input, 'channel name');
+      await user.type(input, 'conversation name');
 
       const create = screen.getByRole('button');
       await user.click(create);
 
-      expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith('channel name');
+      expect(mocks.createConversation).toHaveBeenCalledExactlyOnceWith('conversation name');
     });
 
     it('with an empty name', async () => {
       const create = screen.getByRole('button');
       await user.click(create);
 
-      expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith('');
+      expect(mocks.createConversation).toHaveBeenCalledExactlyOnceWith('');
     });
   });
 });
diff --git a/ui/tests/lib/components/MessageInput.svelte.test.js b/ui/tests/lib/components/MessageInput.svelte.test.js
index c32ce11..b459737 100644
--- a/ui/tests/lib/components/MessageInput.svelte.test.js
+++ b/ui/tests/lib/components/MessageInput.svelte.test.js
@@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({
   sendMessage: vi.fn(),
 }));
 
-describe('CreateChannelForm', async () => {
+describe('MessageInput', async () => {
   beforeEach(async () => {
     render(MessageInput, {
       sendMessage: mocks.sendMessage,
-- 
cgit v1.2.3

