feat: sending messages

This commit is contained in:
Shibo Lyu 2024-09-03 03:45:25 +08:00
parent 09b7d24b95
commit 72b962fb77
8 changed files with 106 additions and 32 deletions

View file

@ -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'); if (payload && !this.keypair) throw new Error('Must make authorized API call with a keypair');
let response: Response; let response: Response;
if (payload) { if (method === 'GET') {
response = await this.fetchWithSignedPayload(`${this.endpoint}${path}`, payload);
} else {
if (this.keypair) { if (this.keypair) {
response = await this.fetchWithAuthHeader(`${this.endpoint}${path}`); response = await this.fetchWithAuthHeader(`${this.endpoint}${path}`);
} else { } else {
@ -66,9 +64,11 @@ export class BlahChatServerConnection {
headers: BlahChatServerConnection.commonHeaders 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(); return await response.json();
} }
@ -82,20 +82,20 @@ export class BlahChatServerConnection {
user: this.keypair.id 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> { async sendMessage(room: string, message: BlahRichText): Promise<void> {
if (!this.keypair) throw new Error('Must send message with a keypair'); if (!this.keypair) throw new Error('Must send message with a keypair');
const payload: BlahMessage = { room, rich_text: message, typ: 'chat' }; 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( async fetchRoom(
roomId: string roomId: string
): Promise<{ room: BlahRoomInfo; messages: BlahSignedPayload<BlahMessage>[] }> { ): Promise<{ room: BlahRoomInfo; messages: BlahSignedPayload<BlahMessage>[] }> {
const [room, messages]: [BlahRoomInfo, [number, 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) }; return { room, messages: messages.toSorted(([a], [b]) => a - b).map(([, message]) => message) };
} }

View file

@ -1,14 +1,16 @@
export class BlahError extends Error { export class BlahError extends Error {
statusCode: number;
raw: Record<string, unknown>; raw: Record<string, unknown>;
constructor(errRespJson: { message: string } & Record<string, unknown>) { constructor(statusCode: number, errRespJson: { message: string } & Record<string, unknown>) {
super(errRespJson.message); super(errRespJson.message);
this.statusCode = statusCode;
this.raw = errRespJson; this.raw = errRespJson;
this.name = 'BlahError'; this.name = 'BlahError';
} }
static async fromResponse(response: Response): Promise<BlahError> { static async fromResponse(response: Response): Promise<BlahError> {
const errRespJson = await response.json(); const errRespJson = await response.json();
return new BlahError(errRespJson); return new BlahError(response.status, errRespJson);
} }
} }

View file

@ -5,6 +5,7 @@
import { tw } from '$lib/tw'; import { tw } from '$lib/tw';
export let delta: Delta | null = null; export let delta: Delta | null = null;
export let plainText: string | undefined = undefined;
export let placeholder: string = ''; export let placeholder: string = '';
let className = ''; let className = '';
@ -23,7 +24,7 @@
<p>{placeholder}</p> <p>{placeholder}</p>
</div> </div>
{:then Input} {:then Input}
<svelte:component this={Input} bind:delta {placeholder}> <svelte:component this={Input} bind:delta bind:plainText {placeholder} on:keydown>
<slot /> <slot />
</svelte:component> </svelte:component>
{/await} {/await}

View file

@ -2,6 +2,7 @@
import { Delta, Editor, asRoot, h } from 'typewriter-editor'; import { Delta, Editor, asRoot, h } from 'typewriter-editor';
export let delta: Delta = new Delta(); export let delta: Delta = new Delta();
export let plainText: string | undefined = undefined;
export let placeholder: string = ''; export let placeholder: string = '';
const editor = new Editor(); const editor = new Editor();
@ -23,12 +24,13 @@
render: (attributes, children) => h('s', null, children) render: (attributes, children) => h('s', null, children)
}); });
delta = editor.getDelta();
editor.on('change', () => { editor.on('change', () => {
delta = editor.getDelta(); 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> </script>
<div <div
@ -38,6 +40,9 @@
? 'true' ? 'true'
: undefined} : undefined}
data-weblah-placeholder={placeholder} data-weblah-placeholder={placeholder}
on:keydown
role="textbox"
tabindex="0"
> >
<slot /> <slot />
</div> </div>

View file

@ -1,5 +1,10 @@
import { persisted } from 'svelte-persisted-store'; import { persisted } from 'svelte-persisted-store';
import type { EncodedBlahKeyPair } from './blah/crypto'; import type { EncodedBlahKeyPair } from './blah/crypto';
import { derived } from 'svelte/store';
export const keyStore = persisted<EncodedBlahKeyPair[]>('weblah-keypairs', []); export const keyStore = persisted<EncodedBlahKeyPair[]>('weblah-keypairs', []);
export const currentKeyIndex = persisted<number>('weblah-current-key-index', 0); export const currentKeyIndex = persisted<number>('weblah-current-key-index', 0);
export const currentKeyPair = derived(
[keyStore, currentKeyIndex],
([keyStore, currentKeyIndex]) => keyStore[currentKeyIndex]
);

View file

@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import * as DropdownMenu from '$lib/components/DropdownMenu'; import * as DropdownMenu from '$lib/components/DropdownMenu';
import { AvatarBeam } from 'svelte-boring-avatars'; 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'; import { BlahKeyPair, generateName } from '$lib/blah/crypto';
let currentKeyId: string | undefined; let currentKeyId: string | undefined;
let currentKeyName: string | null; let currentKeyName: string | null;
$: { $: {
currentKeyId = $keyStore[$currentKeyIndex]?.id; currentKeyId = $currentKeyPair?.id;
currentKeyName = currentKeyId ? generateName(currentKeyId) : null; currentKeyName = currentKeyId ? generateName(currentKeyId) : null;
} }

View file

@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import BgPattern from '$lib/components/BgPattern.svelte'; import BgPattern from '$lib/components/BgPattern.svelte';
import { createRandomMessage } from '$lib/mock/messages';
import { messageFromBlah, type Chat, type Message } from '$lib/types'; import { messageFromBlah, type Chat, type Message } from '$lib/types';
import { onMount } from 'svelte'; import { onDestroy } from 'svelte';
import ChatHeader from './ChatHeader.svelte'; import ChatHeader from './ChatHeader.svelte';
import ChatHistory from './ChatHistory.svelte'; import ChatHistory from './ChatHistory.svelte';
import ChatInput from './ChatInput.svelte'; import ChatInput from './ChatInput.svelte';
import { BlahChatServerConnection } from '$lib/blah/connection/chatServer'; 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; const roomId = $page.params.chatId;
@ -17,6 +19,19 @@
type: 'group' type: 'group'
}; };
let messages: Message[] = []; 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) { async function loadChat(server: BlahChatServerConnection) {
const { room, messages: blahMessages } = await server.fetchRoom(roomId); const { room, messages: blahMessages } = await server.fetchRoom(roomId);
@ -28,20 +43,15 @@
messages = [...blahMessages.map(messageFromBlah), ...messages]; messages = [...blahMessages.map(messageFromBlah), ...messages];
} }
onMount(() => { $: if (browser) initConnection($currentKeyPair).then((server) => loadChat(server));
const server = new BlahChatServerConnection('https://blah.oxa.li/api');
loadChat(server); onDestroy(() => unsubscribe());
const { unsubscribe } = server.subscribeRoom(roomId, (message) => {
messages = [...messages, messageFromBlah(message)];
});
return unsubscribe;
});
</script> </script>
<div class="flex h-full w-full flex-col justify-stretch"> <div class="flex h-full w-full flex-col justify-stretch">
<ChatHeader {chat} outsideUnreadCount={263723} /> <ChatHeader {chat} outsideUnreadCount={263723} />
<BgPattern class="flex-1" pattern="charlieBrown"> <BgPattern class="flex-1" pattern="charlieBrown">
<ChatHistory {messages} mySenderId={'_send'} /> <ChatHistory {messages} mySenderId={$currentKeyPair?.id} />
</BgPattern> </BgPattern>
<ChatInput /> <ChatInput {roomId} {server} />
</div> </div>

View file

@ -1,12 +1,57 @@
<script lang="ts"> <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 Button from '$lib/components/Button.svelte';
import RichTextInput from '$lib/components/RichTextInput.svelte'; import RichTextInput from '$lib/components/RichTextInput.svelte';
import { deltaToBlahRichText } from '$lib/richText';
import type { Delta } from 'typewriter-editor'; 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> </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"> <Button class="p-1.5">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -24,8 +69,14 @@
</svg> </svg>
<span class="sr-only">Attach</span> <span class="sr-only">Attach</span>
</Button> </Button>
<RichTextInput bind:delta placeholder="Message" class="max-h-40 flex-1" /> <RichTextInput
<Button class="p-1.5" variant="primary"> 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -38,4 +89,4 @@
</svg> </svg>
<span class="sr-only">Send</span> <span class="sr-only">Send</span>
</Button> </Button>
</div> </form>