mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-06-01 14:41:08 +00:00
refactor: Chat connection code split into message and room managers
This commit is contained in:
parent
6bbd7a6428
commit
c885f66847
7 changed files with 183 additions and 82 deletions
|
@ -1,2 +1,3 @@
|
|||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
|
|
|
@ -7,25 +7,36 @@ import { BlahError } from './error';
|
|||
const RECONNECT_TIMEOUT = 1500;
|
||||
const RECONNECT_MAX_TRIES = 5;
|
||||
|
||||
export type MessageListener = (message: BlahSignedPayload<BlahMessage>) => void;
|
||||
export interface MessageFilter {
|
||||
roomID?: string;
|
||||
}
|
||||
export interface MessageSubscription {
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
export class BlahChatServerConnection {
|
||||
private static commonHeaders = { 'x-blah-client': `Weblah/${version}` };
|
||||
|
||||
private endpoint_: string;
|
||||
private keypair: BlahKeyPair | null;
|
||||
private keypair_: BlahKeyPair | null;
|
||||
|
||||
get endpoint() {
|
||||
return this.endpoint_;
|
||||
}
|
||||
|
||||
get keypair() {
|
||||
return this.keypair_;
|
||||
}
|
||||
|
||||
private webSocket: WebSocket | null = null;
|
||||
private roomListeners: Map<string, Set<(message: BlahSignedPayload<BlahMessage>) => void>> =
|
||||
new Map();
|
||||
private serverListeners: Set<(message: BlahSignedPayload<BlahMessage>) => void> = new Set();
|
||||
private roomListeners: Map<string, Set<MessageListener>> = new Map();
|
||||
private serverListeners: Set<MessageListener> = new Set();
|
||||
private webSocketRetryTimeout: number | null = null;
|
||||
|
||||
constructor(endpoint: string, keypair: BlahKeyPair | null = null) {
|
||||
this.endpoint_ = endpoint;
|
||||
this.keypair = keypair;
|
||||
this.keypair_ = keypair;
|
||||
}
|
||||
|
||||
private async generateAuthHeader(): Promise<{ Authorization: string }> {
|
||||
|
@ -62,7 +73,7 @@ export class BlahChatServerConnection {
|
|||
});
|
||||
}
|
||||
|
||||
private async apiCall<P, R>(method: 'POST' | 'GET', path: `/${string}`, payload?: P): Promise<R> {
|
||||
public 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');
|
||||
|
||||
let response: Response;
|
||||
|
@ -82,53 +93,18 @@ export class BlahChatServerConnection {
|
|||
return await response.json();
|
||||
}
|
||||
|
||||
async joinRoom(id: string): Promise<void> {
|
||||
if (!this.keypair) throw new Error('Must join with a keypair');
|
||||
async tryRegisterIfNoyYet(): Promise<void> {
|
||||
if (!this.keypair) throw new Error('Must register with a keypair');
|
||||
|
||||
const payload: BlahUserJoinMessage = {
|
||||
typ: 'add_member',
|
||||
room: id,
|
||||
permission: 1,
|
||||
user: this.keypair.id
|
||||
};
|
||||
|
||||
await this.apiCall('POST', `/room/${id}/admin`, payload);
|
||||
}
|
||||
|
||||
private async fetchRoomList(filter: 'joined' | 'public'): Promise<BlahRoomInfo[]> {
|
||||
const { rooms }: { rooms: BlahRoomInfo[] } = await this.apiCall(
|
||||
'GET',
|
||||
`/room?filter=${filter}`
|
||||
);
|
||||
return rooms;
|
||||
}
|
||||
|
||||
async fetchJoinedRooms(): Promise<BlahRoomInfo[]> {
|
||||
if (!this.keypair) return [];
|
||||
return await this.fetchRoomList('joined');
|
||||
}
|
||||
|
||||
async discoverRooms(): Promise<BlahRoomInfo[]> {
|
||||
return await this.fetchRoomList('public');
|
||||
}
|
||||
|
||||
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.apiCall('POST', `/room/${room}/item`, payload);
|
||||
}
|
||||
|
||||
async fetchRoomInfo(roomId: string): Promise<BlahRoomInfo> {
|
||||
const room: BlahRoomInfo = await this.apiCall('GET', `/room/${roomId}`);
|
||||
return room;
|
||||
}
|
||||
|
||||
async fetchRoomHistory(roomId: string): Promise<BlahSignedPayload<BlahMessage>[]> {
|
||||
const { items }: { items: BlahSignedPayload<BlahMessage>[] } = await this.apiCall(
|
||||
'GET',
|
||||
`/room/${roomId}/item`
|
||||
);
|
||||
return items;
|
||||
try {
|
||||
await this.apiCall('GET', '/user/me');
|
||||
} catch (e) {
|
||||
if (e instanceof BlahError && e.statusCode === 404) {
|
||||
// TODO: Register user
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createWebSocket(remainingTries: number = RECONNECT_MAX_TRIES - 1): WebSocket {
|
||||
|
@ -180,41 +156,36 @@ export class BlahChatServerConnection {
|
|||
}
|
||||
|
||||
changeKeyPair(keypair: BlahKeyPair | null) {
|
||||
this.keypair = keypair;
|
||||
this.keypair_ = keypair;
|
||||
if (this.webSocket) {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
subscribeRoom(
|
||||
roomId: string,
|
||||
onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void
|
||||
): { unsubscribe: () => void } {
|
||||
const listeners = this.roomListeners.get(roomId) ?? new Set();
|
||||
listeners.add(onNewMessage);
|
||||
this.roomListeners.set(roomId, listeners);
|
||||
subscribe(listener: MessageListener, filter: MessageFilter = {}): MessageSubscription {
|
||||
const roomID = filter.roomID;
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
const listeners = this.roomListeners.get(roomId) ?? new Set();
|
||||
listeners.delete(onNewMessage);
|
||||
if (listeners.size === 0) {
|
||||
this.roomListeners.delete(roomId);
|
||||
if (roomID) {
|
||||
const listeners = this.roomListeners.get(roomID) ?? new Set();
|
||||
listeners.add(listener);
|
||||
this.roomListeners.set(roomID, listeners);
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
const listeners = this.roomListeners.get(roomID) ?? new Set();
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
this.roomListeners.delete(roomID);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void): {
|
||||
unsubscribe: () => void;
|
||||
} {
|
||||
this.serverListeners.add(onNewMessage);
|
||||
this.serverListeners.add(listener);
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
this.serverListeners.delete(onNewMessage);
|
||||
}
|
||||
unsubscribe: () => this.serverListeners.delete(listener)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,26 @@
|
|||
import { blahErrorResponseSchema } from '../structures/error';
|
||||
|
||||
export class BlahError extends Error {
|
||||
statusCode: number;
|
||||
raw: Record<string, unknown>;
|
||||
raw: unknown;
|
||||
blahCode: string | null = null;
|
||||
|
||||
constructor(statusCode: number, errRespJSON: unknown) {
|
||||
const parsed = blahErrorResponseSchema.safeParse(errRespJSON);
|
||||
if (parsed.success) {
|
||||
super(parsed.data.error.message);
|
||||
} else {
|
||||
super();
|
||||
}
|
||||
|
||||
constructor(statusCode: number, errRespJson: { message: string } & Record<string, unknown>) {
|
||||
super(errRespJson.message);
|
||||
this.statusCode = statusCode;
|
||||
this.raw = errRespJson;
|
||||
this.raw = errRespJSON;
|
||||
this.name = 'BlahError';
|
||||
this.blahCode = parsed.success ? parsed.data.error.code : null;
|
||||
}
|
||||
|
||||
static async fromResponse(response: Response): Promise<BlahError> {
|
||||
const errRespJson = await response.json();
|
||||
return new BlahError(response.status, errRespJson);
|
||||
const errRespJSON = await response.json();
|
||||
return new BlahError(response.status, errRespJSON);
|
||||
}
|
||||
}
|
||||
|
|
35
src/lib/blah/connection/messageManager.svelte.ts
Normal file
35
src/lib/blah/connection/messageManager.svelte.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import type { BlahRichText } from '@blah-im/core/richText';
|
||||
import type { BlahSignedPayload } from '@blah-im/core/crypto';
|
||||
|
||||
import type { BlahChatServerConnection } from './chatServer';
|
||||
import type { BlahMessage } from '../structures';
|
||||
|
||||
export default class MessageManager {
|
||||
connection: BlahChatServerConnection;
|
||||
roomID: string;
|
||||
messages: BlahSignedPayload<BlahMessage>[] = $state([]);
|
||||
|
||||
constructor(connection: BlahChatServerConnection, roomID: string) {
|
||||
this.connection = connection;
|
||||
this.roomID = roomID;
|
||||
}
|
||||
|
||||
async sendMessage(message: BlahRichText): Promise<void> {
|
||||
if (!this.connection.keypair) throw new Error('Must send message with a keypair');
|
||||
const payload: BlahMessage = { room: this.roomID, rich_text: message, typ: 'chat' };
|
||||
await this.connection.apiCall('POST', `/room/${payload.room}/item`, payload);
|
||||
}
|
||||
|
||||
async fetchRoomHistory() {
|
||||
const { items }: { items: BlahSignedPayload<BlahMessage>[] } = await this.connection.apiCall(
|
||||
'GET',
|
||||
`/room/${this.roomID}/item`
|
||||
);
|
||||
this.messages = items;
|
||||
}
|
||||
|
||||
listen() {
|
||||
return this.connection.subscribe((m) => this.messages.push(m), { roomID: this.roomID })
|
||||
.unsubscribe;
|
||||
}
|
||||
}
|
53
src/lib/blah/connection/roomManager.svelte.ts
Normal file
53
src/lib/blah/connection/roomManager.svelte.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import type { BlahRoomInfo, BlahUserJoinMessage } from '../structures';
|
||||
import type { BlahChatServerConnection } from './chatServer';
|
||||
|
||||
export default class RoomManager {
|
||||
connection: BlahChatServerConnection;
|
||||
joinedRooms: BlahRoomInfo[] = $state([]);
|
||||
publicRooms: BlahRoomInfo[] = $state([]);
|
||||
|
||||
constructor(connection: BlahChatServerConnection) {
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
async joinRoom(id: string): Promise<void> {
|
||||
const keypair = this.connection.keypair;
|
||||
if (!keypair) throw new Error('Must join with a keypair');
|
||||
|
||||
const payload: BlahUserJoinMessage = {
|
||||
typ: 'add_member',
|
||||
room: id,
|
||||
permission: 1,
|
||||
user: keypair.id
|
||||
};
|
||||
|
||||
await this.connection.apiCall('POST', `/room/${id}/admin`, payload);
|
||||
}
|
||||
|
||||
private async fetchRoomList(filter: 'joined' | 'public'): Promise<BlahRoomInfo[]> {
|
||||
const { rooms }: { rooms: BlahRoomInfo[] } = await this.connection.apiCall(
|
||||
'GET',
|
||||
`/room?filter=${filter}`
|
||||
);
|
||||
return rooms;
|
||||
}
|
||||
|
||||
async fetchJoinedRooms() {
|
||||
if (!this.connection.keypair) return [];
|
||||
this.joinedRooms = await this.fetchRoomList('joined');
|
||||
}
|
||||
|
||||
async discoverRooms() {
|
||||
this.publicRooms = await this.fetchRoomList('public');
|
||||
}
|
||||
|
||||
async updateRoomInfo(roomId: string) {
|
||||
const room: BlahRoomInfo = await this.connection.apiCall('GET', `/room/${roomId}`);
|
||||
const index = this.joinedRooms.findIndex((r) => r.rid === roomId);
|
||||
if (index !== -1) {
|
||||
this.joinedRooms[index] = room;
|
||||
} else {
|
||||
this.joinedRooms.push(room);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1 +1,17 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export type BlahAuth = { typ: 'auth' };
|
||||
|
||||
export type BlahUserRegisterChallenge = {
|
||||
pow: {
|
||||
nonce: number;
|
||||
difficulty: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const blahUserRegisterChallengeSchema = z.object({
|
||||
pow: z.object({
|
||||
nonce: z.number().int(),
|
||||
difficulty: z.number().int()
|
||||
})
|
||||
});
|
||||
|
|
15
src/lib/blah/structures/error.ts
Normal file
15
src/lib/blah/structures/error.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export interface BlahErrorResponse {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const blahErrorResponseSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string()
|
||||
})
|
||||
});
|
Loading…
Add table
Reference in a new issue