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