From cd68c982c8a5b06ea05bf41c79b030eb2b7676a5 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Sun, 1 Sep 2024 17:51:07 +0800 Subject: [PATCH] feat: receive messages --- src/lib/blah/connection/chatServer.ts | 152 ++++++++++++++++++ src/lib/blah/connection/error.ts | 14 ++ src/lib/blah/{tests => crypto}/crypto.test.ts | 2 +- src/lib/blah/crypto/index.ts | 4 + src/lib/blah/{ => crypto}/keypair.ts | 0 src/lib/blah/{ => crypto}/publicIdentity.ts | 0 src/lib/blah/{ => crypto}/signedPayload.ts | 0 src/lib/blah/{ => crypto}/utils.ts | 0 src/lib/blah/structures/auth.ts | 1 + src/lib/blah/structures/index.ts | 4 + src/lib/blah/structures/message.ts | 7 + src/lib/blah/structures/roomInfo.ts | 3 + src/lib/blah/structures/serviceMessages.ts | 6 + src/lib/components/Button.svelte | 13 +- src/lib/keystore.ts | 4 + src/lib/localstore.ts | 28 ++++ src/lib/types/message.ts | 11 ++ src/routes/(app)/chats/[chatId]/+page.svelte | 48 +++--- .../(app)/chats/[chatId]/ChatInput.svelte | 6 +- 19 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 src/lib/blah/connection/chatServer.ts create mode 100644 src/lib/blah/connection/error.ts rename src/lib/blah/{tests => crypto}/crypto.test.ts (97%) create mode 100644 src/lib/blah/crypto/index.ts rename src/lib/blah/{ => crypto}/keypair.ts (100%) rename src/lib/blah/{ => crypto}/publicIdentity.ts (100%) rename src/lib/blah/{ => crypto}/signedPayload.ts (100%) rename src/lib/blah/{ => crypto}/utils.ts (100%) create mode 100644 src/lib/blah/structures/auth.ts create mode 100644 src/lib/blah/structures/index.ts create mode 100644 src/lib/blah/structures/message.ts create mode 100644 src/lib/blah/structures/roomInfo.ts create mode 100644 src/lib/blah/structures/serviceMessages.ts create mode 100644 src/lib/keystore.ts create mode 100644 src/lib/localstore.ts diff --git a/src/lib/blah/connection/chatServer.ts b/src/lib/blah/connection/chatServer.ts new file mode 100644 index 0000000..a4a5fd6 --- /dev/null +++ b/src/lib/blah/connection/chatServer.ts @@ -0,0 +1,152 @@ +import { version } from '$app/environment'; +import type { BlahRichText } from '$lib/richText'; +import type { BlahKeyPair, BlahSignedPayload } from '../crypto'; +import type { BlahAuth, BlahMessage, BlahRoomInfo, BlahUserJoinMessage } from '../structures'; +import { BlahError } from './error'; + +export class BlahChatServerConnection { + private static commonHeaders = { 'x-blah-client': `Weblah/${version}` }; + + private endpoint: string; + private keypair?: BlahKeyPair; + + private eventSources: Map = new Map(); + private messageListeners: Map void>> = new Map(); + + constructor(endpoint: string, keypair?: BlahKeyPair) { + this.endpoint = endpoint; + this.keypair = keypair; + } + + private async generateAuthHeader(): Promise<{ Authorization: string }> { + if (!this.keypair) throw new Error('Must generate auth header with a keypair'); + const authPayload: BlahAuth = { typ: 'auth' }; + const signedAuthPayload = await this.keypair.signPayload(authPayload); + return { Authorization: JSON.stringify(signedAuthPayload) }; + } + + private async fetchWithAuthHeader(url: string, init?: RequestInit): Promise { + const authHeader = await this.generateAuthHeader(); + return fetch(url, { + ...init, + headers: { ...BlahChatServerConnection.commonHeaders, ...authHeader, ...init?.headers } + }); + } + + private async fetchWithSignedPayload

( + url: string, + payload: P, + init?: RequestInit + ): Promise { + if (!this.keypair) throw new Error('Must fetch with a keypair'); + + const signedPayload = await this.keypair.signPayload(payload); + return fetch(url, { + ...init, + headers: { + ...BlahChatServerConnection.commonHeaders, + 'content-type': 'application/json', + ...init?.headers + }, + body: JSON.stringify(signedPayload) + }); + } + + private async apiCall(path: `/${string}`, payload?: P): Promise { + if (payload && !this.keypair) throw new Error('Must make authorized API call with a keypair'); + + let response: Response; + if (payload) { + response = await this.fetchWithSignedPayload(`${this.endpoint}${path}`, payload); + } else { + if (this.keypair) { + response = await this.fetchWithAuthHeader(`${this.endpoint}${path}`); + } else { + response = await fetch(`${this.endpoint}${path}`, { + headers: BlahChatServerConnection.commonHeaders + }); + } + } + + if (!response.ok) throw BlahError.fromResponse(response); + return await response.json(); + } + + async joinRoom(id: string): Promise { + if (!this.keypair) throw new Error('Must join with a keypair'); + + const payload: BlahUserJoinMessage = { + typ: 'add_member', + room: id, + permission: 1, + user: this.keypair.id + }; + + await this.apiCall(`/room/${id}/join`, payload); + } + + async sendMessage(room: string, message: BlahRichText): Promise { + if (!this.keypair) throw new Error('Must send message with a keypair'); + const payload: BlahMessage = { room, rich_text: message, typ: 'chat' }; + await this.fetchWithSignedPayload(`/room/${room}/item`, payload); + } + + async fetchRoom( + roomId: string + ): Promise<{ room: BlahRoomInfo; messages: BlahSignedPayload[] }> { + const [room, messages]: [BlahRoomInfo, [number, BlahSignedPayload][]] = + await this.apiCall(`/room/${roomId}/item`); + return { room, messages: messages.toSorted(([a], [b]) => a - b).map(([, message]) => message) }; + } + + private createEventSource(roomId: string): EventSource { + const source = new EventSource(`${this.endpoint}/room/${roomId}/event`); + const onSourceError = (e: Event) => { + console.error('EventSource error:', e); + this.eventSources.delete(roomId); + // Retry + this.eventSources.set(roomId, this.createEventSource(roomId)); + }; + + source.addEventListener('error', onSourceError); + + // Attach back any existing listeners + const listeners = this.messageListeners.get(roomId) ?? new Set(); + listeners.forEach((listener) => source?.addEventListener('message', listener)); + + return source; + } + + subscribeRoom( + roomId: string, + onNewMessage: (message: BlahSignedPayload) => void + ): { unsubscribe: () => void } { + let source = this.eventSources.get(roomId); + if (!source) { + source = this.createEventSource(roomId); + } + + const listener = (event: MessageEvent) => { + const message = JSON.parse(event.data) as BlahSignedPayload; + onNewMessage(message); + }; + + source.addEventListener('message', listener); + const listeners = this.messageListeners.get(roomId) ?? new Set(); + listeners.add(listener); + this.messageListeners.set(roomId, listeners); + + return { + unsubscribe: () => { + source?.removeEventListener('message', listener); + const listeners = this.messageListeners.get(roomId) ?? new Set(); + listeners.delete(listener); + if (listeners.size === 0) { + source?.close(); + this.eventSources.delete(roomId); + this.messageListeners.delete(roomId); + } + } + }; + } +} diff --git a/src/lib/blah/connection/error.ts b/src/lib/blah/connection/error.ts new file mode 100644 index 0000000..23e053a --- /dev/null +++ b/src/lib/blah/connection/error.ts @@ -0,0 +1,14 @@ +export class BlahError extends Error { + raw: Record; + + constructor(errRespJson: { message: string } & Record) { + super(errRespJson.message); + this.raw = errRespJson; + this.name = 'BlahError'; + } + + static async fromResponse(response: Response): Promise { + const errRespJson = await response.json(); + return new BlahError(errRespJson); + } +} diff --git a/src/lib/blah/tests/crypto.test.ts b/src/lib/blah/crypto/crypto.test.ts similarity index 97% rename from src/lib/blah/tests/crypto.test.ts rename to src/lib/blah/crypto/crypto.test.ts index f494412..a08b82d 100644 --- a/src/lib/blah/tests/crypto.test.ts +++ b/src/lib/blah/crypto/crypto.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest'; -import { BlahKeyPair } from '../keypair'; +import { BlahKeyPair } from '.'; let keypair: BlahKeyPair; diff --git a/src/lib/blah/crypto/index.ts b/src/lib/blah/crypto/index.ts new file mode 100644 index 0000000..011211d --- /dev/null +++ b/src/lib/blah/crypto/index.ts @@ -0,0 +1,4 @@ +export * from './keypair'; +export * from './publicIdentity'; +export * from './signedPayload'; +export * from './utils'; diff --git a/src/lib/blah/keypair.ts b/src/lib/blah/crypto/keypair.ts similarity index 100% rename from src/lib/blah/keypair.ts rename to src/lib/blah/crypto/keypair.ts diff --git a/src/lib/blah/publicIdentity.ts b/src/lib/blah/crypto/publicIdentity.ts similarity index 100% rename from src/lib/blah/publicIdentity.ts rename to src/lib/blah/crypto/publicIdentity.ts diff --git a/src/lib/blah/signedPayload.ts b/src/lib/blah/crypto/signedPayload.ts similarity index 100% rename from src/lib/blah/signedPayload.ts rename to src/lib/blah/crypto/signedPayload.ts diff --git a/src/lib/blah/utils.ts b/src/lib/blah/crypto/utils.ts similarity index 100% rename from src/lib/blah/utils.ts rename to src/lib/blah/crypto/utils.ts diff --git a/src/lib/blah/structures/auth.ts b/src/lib/blah/structures/auth.ts new file mode 100644 index 0000000..6469f74 --- /dev/null +++ b/src/lib/blah/structures/auth.ts @@ -0,0 +1 @@ +export type BlahAuth = { typ: 'auth' }; diff --git a/src/lib/blah/structures/index.ts b/src/lib/blah/structures/index.ts new file mode 100644 index 0000000..22c25a7 --- /dev/null +++ b/src/lib/blah/structures/index.ts @@ -0,0 +1,4 @@ +export * from './auth'; +export * from './message'; +export * from './roomInfo'; +export * from './serviceMessages'; diff --git a/src/lib/blah/structures/message.ts b/src/lib/blah/structures/message.ts new file mode 100644 index 0000000..0c879de --- /dev/null +++ b/src/lib/blah/structures/message.ts @@ -0,0 +1,7 @@ +import type { BlahRichText } from '$lib/richText'; + +export type BlahMessage = { + rich_text: BlahRichText; + room: string; + typ: 'chat'; +}; diff --git a/src/lib/blah/structures/roomInfo.ts b/src/lib/blah/structures/roomInfo.ts new file mode 100644 index 0000000..a56f01e --- /dev/null +++ b/src/lib/blah/structures/roomInfo.ts @@ -0,0 +1,3 @@ +export type BlahRoomInfo = { + title: string; +}; diff --git a/src/lib/blah/structures/serviceMessages.ts b/src/lib/blah/structures/serviceMessages.ts new file mode 100644 index 0000000..4064579 --- /dev/null +++ b/src/lib/blah/structures/serviceMessages.ts @@ -0,0 +1,6 @@ +export type BlahUserJoinMessage = { + room: string; + typ: 'add_member'; + permission: 1; + user: string; +}; diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte index e8e8160..76d8ced 100644 --- a/src/lib/components/Button.svelte +++ b/src/lib/components/Button.svelte @@ -4,8 +4,11 @@ type HTMLButtonOrAnchorAttributes = Partial & Partial; - interface $$Props extends HTMLButtonOrAnchorAttributes {} + interface $$Props extends HTMLButtonOrAnchorAttributes { + variant?: 'primary' | 'secondary'; + } + export let variant: $$Props['variant'] = 'secondary'; let className: string | null = ''; export { className as class }; @@ -17,6 +20,8 @@ {href} class={tw( 'inline-flex cursor-default items-center justify-center rounded-md px-2 py-1 text-sf-secondary shadow-sm ring-1 ring-ss-secondary transition-shadow duration-200 hover:ring-ss-primary active:shadow-inner', + variant === 'primary' && + 'relative text-slate-50 ring-0 duration-200 before:absolute before:-inset-px before:rounded-[7px] before:bg-gradient-to-b before:from-accent-400 before:from-40% before:to-accent-500 before:ring-1 before:ring-inset before:ring-black/10 before:transition-shadow active:before:shadow-inner', className )} {...$$restProps} @@ -24,5 +29,9 @@ role="button" tabindex="0" > - + {#if variant === 'primary'} +

+ {:else} + + {/if} diff --git a/src/lib/keystore.ts b/src/lib/keystore.ts new file mode 100644 index 0000000..794dc10 --- /dev/null +++ b/src/lib/keystore.ts @@ -0,0 +1,4 @@ +import type { EncodedBlahKeyPair } from './blah/crypto'; +import { localStore } from './localstore'; + +export const keyStore = localStore('weblah-keypairs', []); diff --git a/src/lib/localstore.ts b/src/lib/localstore.ts new file mode 100644 index 0000000..815e459 --- /dev/null +++ b/src/lib/localstore.ts @@ -0,0 +1,28 @@ +import { browser } from '$app/environment'; +import { get, writable, type Writable } from 'svelte/store'; + +export function localStore(key: string, initialData: V): Writable { + const store = writable(initialData); + const { subscribe, set } = store; + + if (browser) { + const storedValue = localStorage.getItem(key); + if (storedValue) set(JSON.parse(storedValue)); + } + + return { + subscribe, + set: (v) => { + if (browser) { + localStorage.setItem(key, JSON.stringify(v)); + } + set(v); + }, + update: (cb) => { + const updatedStore = cb(get(store)); + + if (browser) localStorage.setItem(key, JSON.stringify(updatedStore)); + set(updatedStore); + } + }; +} diff --git a/src/lib/types/message.ts b/src/lib/types/message.ts index d14205a..ceb35f0 100644 --- a/src/lib/types/message.ts +++ b/src/lib/types/message.ts @@ -1,3 +1,5 @@ +import type { BlahSignedPayload } from '$lib/blah/crypto'; +import type { BlahMessage } from '$lib/blah/structures'; import type { BlahRichText } from '$lib/richText'; export type Message = { @@ -6,3 +8,12 @@ export type Message = { content: BlahRichText; date: Date; }; + +export function messageFromBlah(payload: BlahSignedPayload): Message { + return { + id: payload.sig, + sender: { id: payload.signee.user, name: payload.signee.user }, + content: payload.signee.payload.rich_text, + date: new Date(payload.signee.timestamp * 1000) + }; +} diff --git a/src/routes/(app)/chats/[chatId]/+page.svelte b/src/routes/(app)/chats/[chatId]/+page.svelte index add9e17..98e6c63 100644 --- a/src/routes/(app)/chats/[chatId]/+page.svelte +++ b/src/routes/(app)/chats/[chatId]/+page.svelte @@ -2,35 +2,43 @@ import { page } from '$app/stores'; import BgPattern from '$lib/components/BgPattern.svelte'; import { createRandomMessage } from '$lib/mock/messages'; - import type { Message } from '$lib/types'; + import { messageFromBlah, type Chat, type Message } from '$lib/types'; import { onMount } from 'svelte'; import ChatHeader from './ChatHeader.svelte'; import ChatHistory from './ChatHistory.svelte'; import ChatInput from './ChatInput.svelte'; + import { BlahChatServerConnection } from '$lib/blah/connection/chatServer'; - let messages: Message[] = [ - ...Array.from({ length: 5 }).map(() => createRandomMessage({})), - ...Array.from({ length: 2 }).map(() => - createRandomMessage({ sender: { id: '_send', name: 'Shibo Lyu' } }) - ) - ]; + const roomId = $page.params.chatId; - // onMount(() => { - // const interval = setInterval( - // () => { - // messages = [...messages, createRandomMessage({})]; - // }, - // 3000 + Math.random() * 10000 - // ); - // return () => clearInterval(interval); - // }); + let chat: Chat = { + id: roomId, + name: '', + type: 'group' + }; + let messages: Message[] = []; + + async function loadChat(server: BlahChatServerConnection) { + const { room, messages: blahMessages } = await server.fetchRoom(roomId); + chat = { + id: roomId, + name: room.title, + type: 'group' + }; + messages = [...blahMessages.map(messageFromBlah), ...messages]; + } + + onMount(() => { + const server = new BlahChatServerConnection('https://blah.oxa.li/api'); + loadChat(server); + return server.subscribeRoom(roomId, (message) => { + messages = [...messages, messageFromBlah(message)]; + }); + });
- + diff --git a/src/routes/(app)/chats/[chatId]/ChatInput.svelte b/src/routes/(app)/chats/[chatId]/ChatInput.svelte index 0be938d..c0c61c8 100644 --- a/src/routes/(app)/chats/[chatId]/ChatInput.svelte +++ b/src/routes/(app)/chats/[chatId]/ChatInput.svelte @@ -25,14 +25,12 @@ Attach -