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