mirror of
				https://github.com/Blah-IM/Weblah.git
				synced 2025-10-31 18:11:38 +00:00 
			
		
		
		
	feat: receive messages
This commit is contained in:
		
							parent
							
								
									a0fd2df1e5
								
							
						
					
					
						commit
						cd68c982c8
					
				
					 19 changed files with 276 additions and 27 deletions
				
			
		
							
								
								
									
										152
									
								
								src/lib/blah/connection/chatServer.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								src/lib/blah/connection/chatServer.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<string, EventSource> = new Map(); | ||||
| 	private messageListeners: Map<string, Set<(event: MessageEvent) => 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<Response> { | ||||
| 		const authHeader = await this.generateAuthHeader(); | ||||
| 		return fetch(url, { | ||||
| 			...init, | ||||
| 			headers: { ...BlahChatServerConnection.commonHeaders, ...authHeader, ...init?.headers } | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	private async fetchWithSignedPayload<P>( | ||||
| 		url: string, | ||||
| 		payload: P, | ||||
| 		init?: RequestInit | ||||
| 	): Promise<Response> { | ||||
| 		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<P, R>(path: `/${string}`, payload?: P): Promise<R> { | ||||
| 		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<void> { | ||||
| 		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<void> { | ||||
| 		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<BlahMessage>[] }> { | ||||
| 		const [room, messages]: [BlahRoomInfo, [number, BlahSignedPayload<BlahMessage>][]] = | ||||
| 			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<BlahMessage>) => 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<BlahMessage>; | ||||
| 			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); | ||||
| 				} | ||||
| 			} | ||||
| 		}; | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										14
									
								
								src/lib/blah/connection/error.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/lib/blah/connection/error.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| export class BlahError extends Error { | ||||
| 	raw: Record<string, unknown>; | ||||
| 
 | ||||
| 	constructor(errRespJson: { message: string } & Record<string, unknown>) { | ||||
| 		super(errRespJson.message); | ||||
| 		this.raw = errRespJson; | ||||
| 		this.name = 'BlahError'; | ||||
| 	} | ||||
| 
 | ||||
| 	static async fromResponse(response: Response): Promise<BlahError> { | ||||
| 		const errRespJson = await response.json(); | ||||
| 		return new BlahError(errRespJson); | ||||
| 	} | ||||
| } | ||||
|  | @ -1,5 +1,5 @@ | |||
| import { test, expect } from 'vitest'; | ||||
| import { BlahKeyPair } from '../keypair'; | ||||
| import { BlahKeyPair } from '.'; | ||||
| 
 | ||||
| let keypair: BlahKeyPair; | ||||
| 
 | ||||
							
								
								
									
										4
									
								
								src/lib/blah/crypto/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/lib/blah/crypto/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| export * from './keypair'; | ||||
| export * from './publicIdentity'; | ||||
| export * from './signedPayload'; | ||||
| export * from './utils'; | ||||
							
								
								
									
										1
									
								
								src/lib/blah/structures/auth.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/lib/blah/structures/auth.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| export type BlahAuth = { typ: 'auth' }; | ||||
							
								
								
									
										4
									
								
								src/lib/blah/structures/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/lib/blah/structures/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| export * from './auth'; | ||||
| export * from './message'; | ||||
| export * from './roomInfo'; | ||||
| export * from './serviceMessages'; | ||||
							
								
								
									
										7
									
								
								src/lib/blah/structures/message.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/lib/blah/structures/message.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| import type { BlahRichText } from '$lib/richText'; | ||||
| 
 | ||||
| export type BlahMessage = { | ||||
| 	rich_text: BlahRichText; | ||||
| 	room: string; | ||||
| 	typ: 'chat'; | ||||
| }; | ||||
							
								
								
									
										3
									
								
								src/lib/blah/structures/roomInfo.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/lib/blah/structures/roomInfo.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | |||
| export type BlahRoomInfo = { | ||||
| 	title: string; | ||||
| }; | ||||
							
								
								
									
										6
									
								
								src/lib/blah/structures/serviceMessages.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/lib/blah/structures/serviceMessages.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| export type BlahUserJoinMessage = { | ||||
| 	room: string; | ||||
| 	typ: 'add_member'; | ||||
| 	permission: 1; | ||||
| 	user: string; | ||||
| }; | ||||
|  | @ -4,8 +4,11 @@ | |||
| 
 | ||||
| 	type HTMLButtonOrAnchorAttributes = Partial<HTMLAnchorAttributes> & Partial<HTMLButtonAttributes>; | ||||
| 
 | ||||
| 	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'} | ||||
| 		<div class="z-10 drop-shadow-[0_-1px_0_theme(colors.black/0.2)]"><slot /></div> | ||||
| 	{:else} | ||||
| 		<slot /> | ||||
| 	{/if} | ||||
| </svelte:element> | ||||
|  |  | |||
							
								
								
									
										4
									
								
								src/lib/keystore.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/lib/keystore.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| import type { EncodedBlahKeyPair } from './blah/crypto'; | ||||
| import { localStore } from './localstore'; | ||||
| 
 | ||||
| export const keyStore = localStore<EncodedBlahKeyPair[]>('weblah-keypairs', []); | ||||
							
								
								
									
										28
									
								
								src/lib/localstore.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/lib/localstore.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import { browser } from '$app/environment'; | ||||
| import { get, writable, type Writable } from 'svelte/store'; | ||||
| 
 | ||||
| export function localStore<V>(key: string, initialData: V): Writable<V> { | ||||
| 	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); | ||||
| 		} | ||||
| 	}; | ||||
| } | ||||
|  | @ -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<BlahMessage>): 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) | ||||
| 	}; | ||||
| } | ||||
|  |  | |||
|  | @ -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)]; | ||||
| 		}); | ||||
| 	}); | ||||
| </script> | ||||
| 
 | ||||
| <div class="flex h-full w-full flex-col justify-stretch"> | ||||
| 	<ChatHeader | ||||
| 		chat={{ id: 'blah', name: 'Blah IM Interest Group', type: 'group' }} | ||||
| 		outsideUnreadCount={263723} | ||||
| 	/> | ||||
| 	<ChatHeader {chat} outsideUnreadCount={263723} /> | ||||
| 	<BgPattern class="flex-1" pattern="charlieBrown"> | ||||
| 		<ChatHistory {messages} mySenderId={'_send'} /> | ||||
| 	</BgPattern> | ||||
|  |  | |||
|  | @ -25,14 +25,12 @@ | |||
| 		<span class="sr-only">Attach</span> | ||||
| 	</Button> | ||||
| 	<RichTextInput bind:delta placeholder="Message" class="max-h-40 flex-1" /> | ||||
| 	<Button | ||||
| 		class="before:from-accent-400 before:to-accent-500 duraion-200 relative p-1.5 ring-0 before:absolute before:-inset-px before:rounded-[7px] before:bg-gradient-to-b before:from-40% before:ring-1 before:ring-inset before:ring-black/10 before:transition-shadow active:before:shadow-inner" | ||||
| 	> | ||||
| 	<Button class="p-1.5"> | ||||
| 		<svg | ||||
| 			xmlns="http://www.w3.org/2000/svg" | ||||
| 			viewBox="0 0 24 24" | ||||
| 			fill="currentColor" | ||||
| 			class="z-10 size-5 text-slate-50 drop-shadow-[0_-1px_0_theme(colors.black/0.2)]" | ||||
| 			class="z-10 size-5" | ||||
| 		> | ||||
| 			<path | ||||
| 				d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Shibo Lyu
						Shibo Lyu