mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-08-21 03:22:40 +00:00
feat: receive messages
This commit is contained in:
parent
a0fd2df1e5
commit
cd68c982c8
19 changed files with 276 additions and 27 deletions
152
src/lib/blah/connection/chatServer.ts
Normal file
152
src/lib/blah/connection/chatServer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
14
src/lib/blah/connection/error.ts
Normal file
14
src/lib/blah/connection/error.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect } from 'vitest';
|
||||
import { BlahKeyPair } from '../keypair';
|
||||
import { BlahKeyPair } from '.';
|
||||
|
||||
let keypair: BlahKeyPair;
|
||||
|
4
src/lib/blah/crypto/index.ts
Normal file
4
src/lib/blah/crypto/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from './keypair';
|
||||
export * from './publicIdentity';
|
||||
export * from './signedPayload';
|
||||
export * from './utils';
|
1
src/lib/blah/structures/auth.ts
Normal file
1
src/lib/blah/structures/auth.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type BlahAuth = { typ: 'auth' };
|
4
src/lib/blah/structures/index.ts
Normal file
4
src/lib/blah/structures/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from './auth';
|
||||
export * from './message';
|
||||
export * from './roomInfo';
|
||||
export * from './serviceMessages';
|
7
src/lib/blah/structures/message.ts
Normal file
7
src/lib/blah/structures/message.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import type { BlahRichText } from '$lib/richText';
|
||||
|
||||
export type BlahMessage = {
|
||||
rich_text: BlahRichText;
|
||||
room: string;
|
||||
typ: 'chat';
|
||||
};
|
3
src/lib/blah/structures/roomInfo.ts
Normal file
3
src/lib/blah/structures/roomInfo.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export type BlahRoomInfo = {
|
||||
title: string;
|
||||
};
|
6
src/lib/blah/structures/serviceMessages.ts
Normal file
6
src/lib/blah/structures/serviceMessages.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type BlahUserJoinMessage = {
|
||||
room: string;
|
||||
typ: 'add_member';
|
||||
permission: 1;
|
||||
user: string;
|
||||
};
|
|
@ -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
4
src/lib/keystore.ts
Normal 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
28
src/lib/localstore.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue