diff --git a/src/lib/blah/connection/chatServer.ts b/src/lib/blah/connection/chatServer.ts index 705a706..93868c4 100644 --- a/src/lib/blah/connection/chatServer.ts +++ b/src/lib/blah/connection/chatServer.ts @@ -1,5 +1,7 @@ import { version } from '$app/environment'; import type { BlahRichText } from '$lib/richText'; +import { messageFromBlah, type Chat, type Message } from '$lib/types'; +import { readable, type Readable } from 'svelte/store'; import type { BlahKeyPair, BlahSignedPayload } from '../crypto'; import type { BlahAuth, BlahMessage, BlahRoomInfo, BlahUserJoinMessage } from '../structures'; import { BlahError } from './error'; @@ -11,14 +13,14 @@ export class BlahChatServerConnection { private static commonHeaders = { 'x-blah-client': `Weblah/${version}` }; private endpoint: string; - private keypair?: BlahKeyPair; + private keypair: BlahKeyPair | null; private webSocket: WebSocket | null = null; private messageListeners: Map<string, Set<(message: BlahSignedPayload<BlahMessage>) => void>> = new Map(); private webSocketRetryTimeout: number | null = null; - constructor(endpoint: string, keypair?: BlahKeyPair) { + constructor(endpoint: string, keypair: BlahKeyPair | null = null) { this.endpoint = endpoint; this.keypair = keypair; } @@ -147,11 +149,29 @@ export class BlahChatServerConnection { return socket; } + connect() { + if (!this.webSocket) this.webSocket = this.createWebSocket(); + } + + disconnect() { + if (this.webSocketRetryTimeout) clearTimeout(this.webSocketRetryTimeout); + this.webSocket?.close(); + this.webSocket = null; + } + + changeKeyPair(keypair: BlahKeyPair | null) { + this.keypair = keypair; + if (this.webSocket) { + this.disconnect(); + this.connect(); + } + } + subscribeRoom( roomId: string, onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void ): { unsubscribe: () => void } { - if (!this.webSocket) this.webSocket = this.createWebSocket(); + if (!this.webSocket) throw new Error('Must connect to WebSocket before subscribing to rooms'); const listeners = this.messageListeners.get(roomId) ?? new Set(); listeners.add(onNewMessage); @@ -164,12 +184,43 @@ export class BlahChatServerConnection { if (listeners.size === 0) { this.messageListeners.delete(roomId); } - if (this.messageListeners.size === 0) { - if (this.webSocketRetryTimeout) clearTimeout(this.webSocketRetryTimeout); - this.webSocket?.close(); - this.webSocket = null; - } } }; } + + chat(chatId: string): { + info: Readable<Chat>; + messages: Readable<Message[]>; + sendMessage: (brt: BlahRichText) => Promise<void>; + } { + const info = readable<Chat>( + { server: this.endpoint, id: chatId, name: '', type: 'group' }, + (set) => { + this.fetchRoomInfo(chatId).then((room) => { + set({ server: this.endpoint, id: chatId, name: room.title, type: 'group' }); + }); + } + ); + + const messages = readable<Message[]>([], (set, update) => { + this.fetchRoomHistory(chatId).then((history) => + update((messages) => [ + ...history.map(messageFromBlah).toSorted((a, b) => a.date.getTime() - b.date.getTime()), + ...messages + ]) + ); + + const { unsubscribe } = this.subscribeRoom(chatId, (message) => { + update((messages) => [...messages, messageFromBlah(message)]); + }); + + return unsubscribe; + }); + + const sendMessage = async (brt: BlahRichText) => { + await this.sendMessage(chatId, brt); + }; + + return { info, messages, sendMessage }; + } } diff --git a/src/lib/chatServers.ts b/src/lib/chatServers.ts new file mode 100644 index 0000000..1761a5d --- /dev/null +++ b/src/lib/chatServers.ts @@ -0,0 +1,63 @@ +import { persisted } from 'svelte-persisted-store'; +import { get } from 'svelte/store'; +import { BlahChatServerConnection } from './blah/connection/chatServer'; +import { BlahKeyPair, type EncodedBlahKeyPair } from './blah/crypto'; +import { currentKeyPair } from './keystore'; + +export const chatServers = persisted<string[]>('weblah-chat-servers', ['https://blah.oxa.li/api']); + +class ChatServerConnectionPool { + private connections: Map<string, BlahChatServerConnection> = new Map(); + private keypair: BlahKeyPair | null = null; + + constructor() { + chatServers.subscribe(this.onChatServersChange.bind(this)); + currentKeyPair.subscribe(this.onKeyPairChange.bind(this)); + } + + connectAll(keypair?: BlahKeyPair) { + for (const endpoint of get(chatServers)) { + const connection = new BlahChatServerConnection(endpoint, keypair); + this.connections.set(endpoint, connection); + connection.connect(); + } + } + disconnectAll() { + for (const connection of this.connections.values()) { + connection.disconnect(); + } + this.connections.clear(); + } + + private async onKeyPairChange(encodedKeyPair: EncodedBlahKeyPair) { + this.keypair = await BlahKeyPair.fromEncoded(encodedKeyPair); + for (const connection of this.connections.values()) { + connection.changeKeyPair(this.keypair); + } + } + + private async onChatServersChange(newChatServers: string[]) { + // Disconnect from chat servers that are no longer in the list + for (const [endpoint, connection] of this.connections.entries()) { + if (!newChatServers.includes(endpoint)) { + connection.disconnect(); + this.connections.delete(endpoint); + } + } + + // Connect to chat servers that are in the list but not yet connected + for (const endpoint of newChatServers) { + if (!this.connections.has(endpoint)) { + const connection = new BlahChatServerConnection(endpoint, this.keypair); + this.connections.set(endpoint, connection); + connection.connect(); + } + } + } + + getConnection(endpoint: string): BlahChatServerConnection | null { + return this.connections.get(endpoint) ?? null; + } +} + +export const chatServerConnectionPool = new ChatServerConnectionPool(); diff --git a/src/routes/(app)/chats/[chatId]/+page.svelte b/src/routes/(app)/chats/[chatId]/+page.svelte deleted file mode 100644 index fc6f94c..0000000 --- a/src/routes/(app)/chats/[chatId]/+page.svelte +++ /dev/null @@ -1,68 +0,0 @@ -<script lang="ts"> - import { page } from '$app/stores'; - import BgPattern from '$lib/components/BgPattern.svelte'; - import { messageFromBlah, type Chat, type Message } from '$lib/types'; - import { onDestroy } from 'svelte'; - import ChatHeader from './ChatHeader.svelte'; - import ChatHistory from './ChatHistory.svelte'; - import ChatInput from './ChatInput.svelte'; - import { BlahChatServerConnection } from '$lib/blah/connection/chatServer'; - import { currentKeyPair } from '$lib/keystore'; - import { BlahKeyPair, type EncodedBlahKeyPair } from '$lib/blah/crypto'; - import { browser } from '$app/environment'; - - const roomId = $page.params.chatId; - - let chat: Chat = { - id: roomId, - name: '', - type: 'group' - }; - let messages: Message[] = []; - let server: BlahChatServerConnection; - let unsubscribe: () => void = () => {}; - - async function initConnection(encodedKeyPair?: EncodedBlahKeyPair) { - messages = []; - const keyPair = encodedKeyPair ? await BlahKeyPair.fromEncoded(encodedKeyPair) : undefined; - server = new BlahChatServerConnection('https://blah.oxa.li/api', keyPair); - unsubscribe(); - unsubscribe = server.subscribeRoom(roomId, (message) => { - messages = [...messages, messageFromBlah(message)]; - }).unsubscribe; - return server; - } - - async function loadChatInfo(server: BlahChatServerConnection) { - const room = await server.fetchRoomInfo(roomId); - chat = { - id: roomId, - name: room.title, - type: 'group' - }; - } - - async function loadChatHistory(server: BlahChatServerConnection) { - const history = await server.fetchRoomHistory(roomId); - messages = [ - ...history.map(messageFromBlah).toSorted((a, b) => a.date.getTime() - b.date.getTime()), - ...messages - ]; - } - - async function loadChat(server: BlahChatServerConnection) { - return await Promise.allSettled([loadChatInfo(server), loadChatHistory(server)]); - } - - $: if (browser) initConnection($currentKeyPair).then((server) => loadChat(server)); - - onDestroy(() => unsubscribe()); -</script> - -<div class="flex h-full w-full flex-col justify-stretch"> - <ChatHeader {chat} outsideUnreadCount={263723} /> - <BgPattern class="flex-1" pattern="charlieBrown"> - <ChatHistory {messages} mySenderId={$currentKeyPair?.id} /> - </BgPattern> - <ChatInput {roomId} {server} /> -</div> diff --git a/src/routes/(app)/chats/[server]/[chatId]/+page.svelte b/src/routes/(app)/chats/[server]/[chatId]/+page.svelte new file mode 100644 index 0000000..6a3ff89 --- /dev/null +++ b/src/routes/(app)/chats/[server]/[chatId]/+page.svelte @@ -0,0 +1,45 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import BgPattern from '$lib/components/BgPattern.svelte'; + import { messageFromBlah, type Chat, type Message } from '$lib/types'; + import { onDestroy } from 'svelte'; + import ChatHeader from './ChatHeader.svelte'; + import ChatHistory from './ChatHistory.svelte'; + import ChatInput from './ChatInput.svelte'; + import { BlahChatServerConnection } from '$lib/blah/connection/chatServer'; + import { currentKeyPair } from '$lib/keystore'; + import { BlahKeyPair, type EncodedBlahKeyPair } from '$lib/blah/crypto'; + import { browser } from '$app/environment'; + import { chatServerConnectionPool } from '$lib/chatServers'; + import ServiceMessage from '$lib/components/ServiceMessage.svelte'; + import ChatPage from './ChatPage.svelte'; + + $: roomId = $page.params.chatId; + + let serverEndpoint: string = ''; + $: { + const endpointString = decodeURIComponent($page.params.server); + serverEndpoint = endpointString.startsWith('http') + ? endpointString + : `https://${endpointString}`; + } + + let server: BlahChatServerConnection | null; + $: { + if (browser) { + server = chatServerConnectionPool.getConnection(serverEndpoint); + } + } +</script> + +<div class="flex h-full w-full flex-col items-center justify-center"> + {#if server} + {@const { info, messages, sendMessage } = server.chat(roomId)} + <ChatPage {info} {messages} on:sendMessage={sendMessage} /> + {:else} + <ServiceMessage> + To view this chat, you need to connect to chat server + <span class="font-semibold">{serverEndpoint}</span>. + </ServiceMessage> + {/if} +</div> diff --git a/src/routes/(app)/chats/[chatId]/ChatHeader.svelte b/src/routes/(app)/chats/[server]/[chatId]/ChatHeader.svelte similarity index 94% rename from src/routes/(app)/chats/[chatId]/ChatHeader.svelte rename to src/routes/(app)/chats/[server]/[chatId]/ChatHeader.svelte index 0cc31e0..98cd57b 100644 --- a/src/routes/(app)/chats/[chatId]/ChatHeader.svelte +++ b/src/routes/(app)/chats/[server]/[chatId]/ChatHeader.svelte @@ -4,7 +4,7 @@ import type { Chat } from '$lib/types'; import { AvatarBeam } from 'svelte-boring-avatars'; - export let chat: Chat; + export let info: Chat; export let outsideUnreadCount = 0; </script> @@ -28,10 +28,10 @@ <span class="sr-only">Back</span> </Button> <div class="flex flex-1 flex-col justify-center text-center sm:order-2 sm:text-start"> - <h3 class="truncate text-sm font-semibold">{chat.name}</h3> + <h3 class="truncate text-sm font-semibold">{info.name}</h3> </div> <div class="sm:order-1"> - <AvatarBeam size={30} name={chat.name} /> + <AvatarBeam size={30} name={info.id} /> </div> <a class="absolute inset-y-0 start-0 hidden w-2 cursor-default sm:block" href="/"> diff --git a/src/routes/(app)/chats/[chatId]/ChatHistory.svelte b/src/routes/(app)/chats/[server]/[chatId]/ChatHistory.svelte similarity index 100% rename from src/routes/(app)/chats/[chatId]/ChatHistory.svelte rename to src/routes/(app)/chats/[server]/[chatId]/ChatHistory.svelte diff --git a/src/routes/(app)/chats/[chatId]/ChatInput.svelte b/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte similarity index 68% rename from src/routes/(app)/chats/[chatId]/ChatInput.svelte rename to src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte index 53fe00b..144c099 100644 --- a/src/routes/(app)/chats/[chatId]/ChatInput.svelte +++ b/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte @@ -1,19 +1,16 @@ <script lang="ts"> - import type { BlahChatServerConnection } from '$lib/blah/connection/chatServer'; - import { BlahError } from '$lib/blah/connection/error'; import Button from '$lib/components/Button.svelte'; import RichTextInput from '$lib/components/RichTextInput.svelte'; - import { deltaToBlahRichText } from '$lib/richText'; + import { deltaToBlahRichText, type BlahRichText } from '$lib/richText'; + import { createEventDispatcher } from 'svelte'; import type { Delta, Editor } from 'typewriter-editor'; - export let roomId: string; - export let server: BlahChatServerConnection | undefined; - let editor: Editor | undefined; let delta: Delta; let plainText: string = ''; let form: HTMLFormElement | null = null; - let sendDisabled = false; + + const dispatch = createEventDispatcher<{ sendMessage: BlahRichText }>(); function onKeyboardSubmit() { editor?.select(null); @@ -21,27 +18,13 @@ } async function submit() { - if (!server || plainText.trim() === '') return; + if (plainText.trim() === '') return; const brt = deltaToBlahRichText(delta); - sendDisabled = true; - try { - await server.sendMessage(roomId, brt); - } catch (e) { - console.log(e); - if (e instanceof BlahError && e.statusCode === 403) { - // TODO: Actual error handling - await server.joinRoom(roomId); - await server.sendMessage(roomId, brt); - } else { - throw e; - } - } - sendDisabled = false; + dispatch('sendMessage', brt); + plainText = ''; } - - $: sendDisabled = !server; </script> <form @@ -75,7 +58,7 @@ keyboardSubmitMethod="enter" on:keyboardSubmit={onKeyboardSubmit} /> - <Button class="p-1.5" variant="primary" type="submit" disabled={sendDisabled}> + <Button class="p-1.5" variant="primary" type="submit"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" diff --git a/src/routes/(app)/chats/[chatId]/ChatMessage.svelte b/src/routes/(app)/chats/[server]/[chatId]/ChatMessage.svelte similarity index 100% rename from src/routes/(app)/chats/[chatId]/ChatMessage.svelte rename to src/routes/(app)/chats/[server]/[chatId]/ChatMessage.svelte diff --git a/src/routes/(app)/chats/[server]/[chatId]/ChatPage.svelte b/src/routes/(app)/chats/[server]/[chatId]/ChatPage.svelte new file mode 100644 index 0000000..3cd4006 --- /dev/null +++ b/src/routes/(app)/chats/[server]/[chatId]/ChatPage.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import type { Readable } from 'svelte/store'; + + import type { Chat, Message } from '$lib/types'; + import BgPattern from '$lib/components/BgPattern.svelte'; + import { currentKeyPair } from '$lib/keystore'; + + import ChatHeader from './ChatHeader.svelte'; + import ChatHistory from './ChatHistory.svelte'; + import ChatInput from './ChatInput.svelte'; + import type { BlahRichText } from '$lib/richText'; + + export let info: Readable<Chat>; + export let messages: Readable<Message[]>; + + type $$Events = { + sendMessage: BlahRichText; + }; +</script> + +<ChatHeader info={$info} outsideUnreadCount={263723} /> +<BgPattern class="flex-1" pattern="charlieBrown"> + <ChatHistory messages={$messages} mySenderId={$currentKeyPair?.id} /> +</BgPattern> +<ChatInput on:sendMessage />