mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-05-01 08:41:08 +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');
|
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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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]
|
||||||
|
);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Reference in a new issue