mirror of
				https://github.com/Blah-IM/Weblah.git
				synced 2025-10-31 18:11:38 +00:00 
			
		
		
		
	feat: sending messages
This commit is contained in:
		
							parent
							
								
									09b7d24b95
								
							
						
					
					
						commit
						72b962fb77
					
				
					 8 changed files with 106 additions and 32 deletions
				
			
		|  | @ -52,13 +52,11 @@ export class BlahChatServerConnection { | |||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
| 	private async apiCall<P, R>(path: `/${string}`, payload?: P): Promise<R> { | ||||
| 	private async apiCall<P, R>(method: 'POST' | 'GET', 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 (method === 'GET') { | ||||
| 			if (this.keypair) { | ||||
| 				response = await this.fetchWithAuthHeader(`${this.endpoint}${path}`); | ||||
| 			} else { | ||||
|  | @ -66,9 +64,11 @@ export class BlahChatServerConnection { | |||
| 					headers: BlahChatServerConnection.commonHeaders | ||||
| 				}); | ||||
| 			} | ||||
| 		} else { | ||||
| 			response = await this.fetchWithSignedPayload(`${this.endpoint}${path}`, payload, { method }); | ||||
| 		} | ||||
| 
 | ||||
| 		if (!response.ok) throw BlahError.fromResponse(response); | ||||
| 		if (!response.ok) throw await BlahError.fromResponse(response); | ||||
| 		return await response.json(); | ||||
| 	} | ||||
| 
 | ||||
|  | @ -82,20 +82,20 @@ export class BlahChatServerConnection { | |||
| 			user: this.keypair.id | ||||
| 		}; | ||||
| 
 | ||||
| 		await this.apiCall(`/room/${id}/join`, payload); | ||||
| 		await this.apiCall('POST', `/room/${id}/admin`, 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); | ||||
| 		await this.apiCall('POST', `/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`); | ||||
| 			await this.apiCall('GET', `/room/${roomId}/item`); | ||||
| 		return { room, messages: messages.toSorted(([a], [b]) => a - b).map(([, message]) => message) }; | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,14 +1,16 @@ | |||
| export class BlahError extends Error { | ||||
| 	statusCode: number; | ||||
| 	raw: Record<string, unknown>; | ||||
| 
 | ||||
| 	constructor(errRespJson: { message: string } & Record<string, unknown>) { | ||||
| 	constructor(statusCode: number, errRespJson: { message: string } & Record<string, unknown>) { | ||||
| 		super(errRespJson.message); | ||||
| 		this.statusCode = statusCode; | ||||
| 		this.raw = errRespJson; | ||||
| 		this.name = 'BlahError'; | ||||
| 	} | ||||
| 
 | ||||
| 	static async fromResponse(response: Response): Promise<BlahError> { | ||||
| 		const errRespJson = await response.json(); | ||||
| 		return new BlahError(errRespJson); | ||||
| 		return new BlahError(response.status, errRespJson); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ | |||
| 	import { tw } from '$lib/tw'; | ||||
| 
 | ||||
| 	export let delta: Delta | null = null; | ||||
| 	export let plainText: string | undefined = undefined; | ||||
| 	export let placeholder: string = ''; | ||||
| 
 | ||||
| 	let className = ''; | ||||
|  | @ -23,7 +24,7 @@ | |||
| 			<p>{placeholder}</p> | ||||
| 		</div> | ||||
| 	{:then Input} | ||||
| 		<svelte:component this={Input} bind:delta {placeholder}> | ||||
| 		<svelte:component this={Input} bind:delta bind:plainText {placeholder} on:keydown> | ||||
| 			<slot /> | ||||
| 		</svelte:component> | ||||
| 	{/await} | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ | |||
| 	import { Delta, Editor, asRoot, h } from 'typewriter-editor'; | ||||
| 
 | ||||
| 	export let delta: Delta = new Delta(); | ||||
| 	export let plainText: string | undefined = undefined; | ||||
| 	export let placeholder: string = ''; | ||||
| 
 | ||||
| 	const editor = new Editor(); | ||||
|  | @ -23,12 +24,13 @@ | |||
| 		render: (attributes, children) => h('s', null, children) | ||||
| 	}); | ||||
| 
 | ||||
| 	delta = editor.getDelta(); | ||||
| 	editor.on('change', () => { | ||||
| 		delta = editor.getDelta(); | ||||
| 		if (typeof plainText === 'string') plainText = editor.getText(); | ||||
| 	}); | ||||
| 
 | ||||
| 	$: editor.setDelta(delta); | ||||
| 	$: editor.setDelta(delta ?? new Delta()); | ||||
| 	$: if (typeof plainText === 'string' && plainText !== editor.getText()) editor.setText(plainText); | ||||
| </script> | ||||
| 
 | ||||
| <div | ||||
|  | @ -38,6 +40,9 @@ | |||
| 		? 'true' | ||||
| 		: undefined} | ||||
| 	data-weblah-placeholder={placeholder} | ||||
| 	on:keydown | ||||
| 	role="textbox" | ||||
| 	tabindex="0" | ||||
| > | ||||
| 	<slot /> | ||||
| </div> | ||||
|  |  | |||
|  | @ -1,5 +1,10 @@ | |||
| import { persisted } from 'svelte-persisted-store'; | ||||
| import type { EncodedBlahKeyPair } from './blah/crypto'; | ||||
| import { derived } from 'svelte/store'; | ||||
| 
 | ||||
| export const keyStore = persisted<EncodedBlahKeyPair[]>('weblah-keypairs', []); | ||||
| export const currentKeyIndex = persisted<number>('weblah-current-key-index', 0); | ||||
| export const currentKeyPair = derived( | ||||
| 	[keyStore, currentKeyIndex], | ||||
| 	([keyStore, currentKeyIndex]) => keyStore[currentKeyIndex] | ||||
| ); | ||||
|  |  | |||
|  | @ -1,13 +1,13 @@ | |||
| <script lang="ts"> | ||||
| 	import * as DropdownMenu from '$lib/components/DropdownMenu'; | ||||
| 	import { AvatarBeam } from 'svelte-boring-avatars'; | ||||
| 	import { keyStore, currentKeyIndex } from '$lib/keystore'; | ||||
| 	import { keyStore, currentKeyIndex, currentKeyPair } from '$lib/keystore'; | ||||
| 	import { BlahKeyPair, generateName } from '$lib/blah/crypto'; | ||||
| 
 | ||||
| 	let currentKeyId: string | undefined; | ||||
| 	let currentKeyName: string | null; | ||||
| 	$: { | ||||
| 		currentKeyId = $keyStore[$currentKeyIndex]?.id; | ||||
| 		currentKeyId = $currentKeyPair?.id; | ||||
| 		currentKeyName = currentKeyId ? generateName(currentKeyId) : null; | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,13 +1,15 @@ | |||
| <script lang="ts"> | ||||
| 	import { page } from '$app/stores'; | ||||
| 	import BgPattern from '$lib/components/BgPattern.svelte'; | ||||
| 	import { createRandomMessage } from '$lib/mock/messages'; | ||||
| 	import { messageFromBlah, type Chat, type Message } from '$lib/types'; | ||||
| 	import { onMount } from 'svelte'; | ||||
| 	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; | ||||
| 
 | ||||
|  | @ -17,6 +19,19 @@ | |||
| 		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 loadChat(server: BlahChatServerConnection) { | ||||
| 		const { room, messages: blahMessages } = await server.fetchRoom(roomId); | ||||
|  | @ -28,20 +43,15 @@ | |||
| 		messages = [...blahMessages.map(messageFromBlah), ...messages]; | ||||
| 	} | ||||
| 
 | ||||
| 	onMount(() => { | ||||
| 		const server = new BlahChatServerConnection('https://blah.oxa.li/api'); | ||||
| 		loadChat(server); | ||||
| 		const { unsubscribe } = server.subscribeRoom(roomId, (message) => { | ||||
| 			messages = [...messages, messageFromBlah(message)]; | ||||
| 		}); | ||||
| 		return unsubscribe; | ||||
| 	}); | ||||
| 	$: 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={'_send'} /> | ||||
| 		<ChatHistory {messages} mySenderId={$currentKeyPair?.id} /> | ||||
| 	</BgPattern> | ||||
| 	<ChatInput /> | ||||
| 	<ChatInput {roomId} {server} /> | ||||
| </div> | ||||
|  |  | |||
|  | @ -1,12 +1,57 @@ | |||
| <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 type { Delta } from 'typewriter-editor'; | ||||
| 
 | ||||
| 	let delta: Delta | null = null; | ||||
| 	export let roomId: string; | ||||
| 	export let server: BlahChatServerConnection | undefined; | ||||
| 
 | ||||
| 	let delta: Delta; | ||||
| 	let plainText: string = ''; | ||||
| 	let form: HTMLFormElement | null = null; | ||||
| 	let sendDisabled = false; | ||||
| 
 | ||||
| 	function onInputKeydown(event: KeyboardEvent) { | ||||
| 		console.log(event.key, event.shiftKey, event.isComposing, plainText); | ||||
| 		if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { | ||||
| 			event.preventDefault(); | ||||
| 			form?.requestSubmit(); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	async function submit() { | ||||
| 		if (!server || plainText.trim() === '') return; | ||||
| 		console.log('submit'); | ||||
| 
 | ||||
| 		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; | ||||
| 		plainText = ''; | ||||
| 	} | ||||
| 
 | ||||
| 	$: sendDisabled = !!server; | ||||
| </script> | ||||
| 
 | ||||
| <div class="flex items-end gap-2 border-t border-ss-secondary bg-sb-primary p-2 shadow-sm"> | ||||
| <form | ||||
| 	class="flex items-end gap-2 border-t border-ss-secondary bg-sb-primary p-2 shadow-sm" | ||||
| 	bind:this={form} | ||||
| 	on:submit|preventDefault={submit} | ||||
| > | ||||
| 	<Button class="p-1.5"> | ||||
| 		<svg | ||||
| 			xmlns="http://www.w3.org/2000/svg" | ||||
|  | @ -24,8 +69,14 @@ | |||
| 		</svg> | ||||
| 		<span class="sr-only">Attach</span> | ||||
| 	</Button> | ||||
| 	<RichTextInput bind:delta placeholder="Message" class="max-h-40 flex-1" /> | ||||
| 	<Button class="p-1.5" variant="primary"> | ||||
| 	<RichTextInput | ||||
| 		bind:delta | ||||
| 		bind:plainText | ||||
| 		placeholder="Message" | ||||
| 		class="max-h-40 flex-1" | ||||
| 		on:keydown={onInputKeydown} | ||||
| 	/> | ||||
| 	<Button class="p-1.5" variant="primary" type="submit" disabled={sendDisabled}> | ||||
| 		<svg | ||||
| 			xmlns="http://www.w3.org/2000/svg" | ||||
| 			viewBox="0 0 24 24" | ||||
|  | @ -38,4 +89,4 @@ | |||
| 		</svg> | ||||
| 		<span class="sr-only">Send</span> | ||||
| 	</Button> | ||||
| </div> | ||||
| </form> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Shibo Lyu
						Shibo Lyu