feat: receive messages

This commit is contained in:
Shibo Lyu 2024-09-01 17:51:07 +08:00
parent a0fd2df1e5
commit cd68c982c8
19 changed files with 276 additions and 27 deletions

View 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);
}
}
};
}
}

View 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);
}
}

View file

@ -1,5 +1,5 @@
import { test, expect } from 'vitest';
import { BlahKeyPair } from '../keypair';
import { BlahKeyPair } from '.';
let keypair: BlahKeyPair;

View file

@ -0,0 +1,4 @@
export * from './keypair';
export * from './publicIdentity';
export * from './signedPayload';
export * from './utils';

View file

@ -0,0 +1 @@
export type BlahAuth = { typ: 'auth' };

View file

@ -0,0 +1,4 @@
export * from './auth';
export * from './message';
export * from './roomInfo';
export * from './serviceMessages';

View file

@ -0,0 +1,7 @@
import type { BlahRichText } from '$lib/richText';
export type BlahMessage = {
rich_text: BlahRichText;
room: string;
typ: 'chat';
};

View file

@ -0,0 +1,3 @@
export type BlahRoomInfo = {
title: string;
};

View file

@ -0,0 +1,6 @@
export type BlahUserJoinMessage = {
room: string;
typ: 'add_member';
permission: 1;
user: string;
};

View file

@ -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"
>
<slot />
{#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
View 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
View 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);
}
};
}

View file

@ -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)
};
}

View file

@ -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>

View file

@ -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"