feat: chat list (joined)

This commit is contained in:
Shibo Lyu 2024-09-04 05:28:10 +08:00
parent 48c5ed4687
commit 9e899bbb27
10 changed files with 162 additions and 126 deletions

View file

@ -95,6 +95,23 @@ export class BlahChatServerConnection {
await this.apiCall('POST', `/room/${id}/admin`, payload); 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> { async sendMessage(room: string, message: BlahRichText): Promise<void> {
if (!this.keypair) throw new Error('Must send message with a keypair'); if (!this.keypair) throw new Error('Must send message with a keypair');
const payload: BlahMessage = { room, rich_text: message, typ: 'chat' }; const payload: BlahMessage = { room, rich_text: message, typ: 'chat' };
@ -153,7 +170,7 @@ export class BlahChatServerConnection {
} }
connect() { connect() {
if (!this.webSocket) this.webSocket = this.createWebSocket(); if (!this.webSocket && this.keypair) this.webSocket = this.createWebSocket();
} }
disconnect() { disconnect() {
@ -174,8 +191,6 @@ export class BlahChatServerConnection {
roomId: string, roomId: string,
onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void
): { unsubscribe: () => void } { ): { unsubscribe: () => void } {
if (!this.webSocket) throw new Error('Must connect to WebSocket before subscribing to rooms');
const listeners = this.roomListeners.get(roomId) ?? new Set(); const listeners = this.roomListeners.get(roomId) ?? new Set();
listeners.add(onNewMessage); listeners.add(onNewMessage);
this.roomListeners.set(roomId, listeners); this.roomListeners.set(roomId, listeners);
@ -194,9 +209,6 @@ export class BlahChatServerConnection {
subscribe(onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void): { subscribe(onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void): {
unsubscribe: () => void; unsubscribe: () => void;
} { } {
if (!this.webSocket)
throw new Error('Must connect to WebSocket before subscribing to messages');
this.serverListeners.add(onNewMessage); this.serverListeners.add(onNewMessage);
return { return {

View file

@ -1,3 +1,8 @@
import type { BlahSignedPayload } from '../crypto';
import type { BlahMessage } from './message';
export type BlahRoomInfo = { export type BlahRoomInfo = {
ruuid: string;
title: string; title: string;
last_chat?: BlahSignedPayload<BlahMessage>;
}; };

68
src/lib/chatList.ts Normal file
View file

@ -0,0 +1,68 @@
import { readable, writable, type Readable, type Writable } from 'svelte/store';
import { chatFromBlah, type Chat } from './types';
import type { BlahMessage, BlahRoomInfo } from './blah/structures';
import type { BlahSignedPayload } from './blah/crypto';
export class ChatListManager {
chatList: Writable<Chat[]>;
constructor() {
this.chatList = writable<Chat[]>([]);
}
private sortChats(chatList: Chat[]) {
chatList.sort(
(a, b) =>
(b.lastMessage?.date ?? new Date(1970, 0, 1)).getTime() ??
-(a.lastMessage?.date ?? new Date(1970, 0, 1)).getTime()
);
}
ingestChats(chats: BlahRoomInfo[], serverEndpoint: string) {
this.chatList.update((chatList) => {
for (const chat of chats) {
const newChat = chatFromBlah(chat, serverEndpoint);
const existing = chatList.find((c) => c.id === chat.ruuid);
if (existing) {
existing.name = newChat.name;
existing.lastMessage = newChat.lastMessage ?? existing.lastMessage;
} else {
chatList.push(newChat);
console.log('new chat added to list', newChat);
}
}
this.sortChats(chatList);
return chatList;
});
}
ingestMessage(message: BlahSignedPayload<BlahMessage>, serverEndpoint: string) {
this.chatList.update((chatList) => {
const chat = chatList.find((c) => c.id === message.signee.payload.room);
if (chat) {
const newChat = chatFromBlah(
{ ruuid: chat.id, title: chat.name, last_chat: message },
serverEndpoint
);
chat.lastMessage = newChat.lastMessage ?? chat.lastMessage;
}
this.sortChats(chatList);
return chatList;
});
}
leaveChatServers(serverEndpoints: string[]) {
this.chatList.update((chatList) => {
return chatList.filter((chat) => serverEndpoints.includes(chat.server));
});
}
}
export function useChatList(manager: ChatListManager): Readable<Chat[]> {
return readable<Chat[]>([], (set) => {
const unsubscribe = manager.chatList.subscribe(set);
return unsubscribe;
});
}

View file

@ -3,23 +3,44 @@ import { get } from 'svelte/store';
import { BlahChatServerConnection } from './blah/connection/chatServer'; import { BlahChatServerConnection } from './blah/connection/chatServer';
import { BlahKeyPair, type EncodedBlahKeyPair } from './blah/crypto'; import { BlahKeyPair, type EncodedBlahKeyPair } from './blah/crypto';
import { currentKeyPair } from './keystore'; import { currentKeyPair } from './keystore';
import { ChatListManager } from './chatList';
import { browser } from '$app/environment';
export const chatServers = persisted<string[]>('weblah-chat-servers', ['https://blah.oxa.li/api']); export const chatServers = persisted<string[]>('weblah-chat-servers', ['https://blah.oxa.li/api']);
class ChatServerConnectionPool { class ChatServerConnectionPool {
private connections: Map<string, BlahChatServerConnection> = new Map(); private connections: Map<string, BlahChatServerConnection> = new Map();
private keypair: BlahKeyPair | null = null; private keypair: BlahKeyPair | null = null;
chatList: ChatListManager = new ChatListManager();
constructor() { constructor() {
if (browser) {
chatServers.subscribe(this.onChatServersChange.bind(this)); chatServers.subscribe(this.onChatServersChange.bind(this));
currentKeyPair.subscribe(this.onKeyPairChange.bind(this)); currentKeyPair.subscribe(this.onKeyPairChange.bind(this));
} }
}
connectAll(keypair?: BlahKeyPair) { private createAndConnect(endpoint: string) {
for (const endpoint of get(chatServers)) { const connection = new BlahChatServerConnection(endpoint, this.keypair);
const connection = new BlahChatServerConnection(endpoint, keypair);
this.connections.set(endpoint, connection); this.connections.set(endpoint, connection);
connection.connect(); connection.connect();
this.setupChatList(connection);
}
private fetchJoinedRooms(connection: BlahChatServerConnection) {
connection
.fetchJoinedRooms()
.then((rooms) => this.chatList.ingestChats(rooms, connection.endpoint));
}
private setupChatList(connection: BlahChatServerConnection) {
this.fetchJoinedRooms(connection);
connection.subscribe((message) => this.chatList.ingestMessage(message, connection.endpoint));
}
connectAll() {
for (const endpoint of get(chatServers)) {
this.createAndConnect(endpoint);
} }
} }
disconnectAll() { disconnectAll() {
@ -33,24 +54,27 @@ class ChatServerConnectionPool {
this.keypair = await BlahKeyPair.fromEncoded(encodedKeyPair); this.keypair = await BlahKeyPair.fromEncoded(encodedKeyPair);
for (const connection of this.connections.values()) { for (const connection of this.connections.values()) {
connection.changeKeyPair(this.keypair); connection.changeKeyPair(this.keypair);
connection.connect();
this.fetchJoinedRooms(connection);
} }
} }
private async onChatServersChange(newChatServers: string[]) { private async onChatServersChange(newChatServers: string[]) {
// Disconnect from chat servers that are no longer in the list // Disconnect from chat servers that are no longer in the list
const disconnectedEndpoints: string[] = [];
for (const [endpoint, connection] of this.connections.entries()) { for (const [endpoint, connection] of this.connections.entries()) {
if (!newChatServers.includes(endpoint)) { if (!newChatServers.includes(endpoint)) {
connection.disconnect(); connection.disconnect();
this.connections.delete(endpoint); this.connections.delete(endpoint);
disconnectedEndpoints.push(endpoint);
} }
} }
this.chatList.leaveChatServers(disconnectedEndpoints);
// Connect to chat servers that are in the list but not yet connected // Connect to chat servers that are in the list but not yet connected
for (const endpoint of newChatServers) { for (const endpoint of newChatServers) {
if (!this.connections.has(endpoint)) { if (!this.connections.has(endpoint)) {
const connection = new BlahChatServerConnection(endpoint, this.keypair); this.createAndConnect(endpoint);
this.connections.set(endpoint, connection);
connection.connect();
} }
} }
} }

View file

@ -1,4 +1,5 @@
import type { Message } from './message'; import type { BlahRoomInfo } from '$lib/blah/structures';
import { messageFromBlah, type Message } from './message';
export type Chat = { export type Chat = {
server: string; server: string;
@ -9,3 +10,13 @@ export type Chat = {
lastMessage?: Message; lastMessage?: Message;
unreadCount?: number; unreadCount?: number;
}; };
export function chatFromBlah(room: BlahRoomInfo, serverEndpoint: string): Chat {
return {
server: serverEndpoint,
id: room.ruuid,
name: room.title,
type: 'group',
lastMessage: room.last_chat ? messageFromBlah(room.last_chat) : undefined
};
}

View file

@ -1,115 +1,22 @@
<script> <script>
import { browser } from '$app/environment';
import { useChatList } from '$lib/chatList';
import { chatServerConnectionPool } from '$lib/chatServers';
import ChatListHeader from './ChatListHeader.svelte'; import ChatListHeader from './ChatListHeader.svelte';
import ChatListItem from './ChatListItem.svelte'; import ChatListItem from './ChatListItem.svelte';
const chatList = browser ? useChatList(chatServerConnectionPool.chatList) : null;
</script> </script>
<div class="flex h-[100dvh] flex-col justify-stretch"> <div class="flex h-[100dvh] flex-col justify-stretch">
<ChatListHeader /> <ChatListHeader />
<div class="min-h-0 flex-1 touch-pan-y overflow-y-auto"> <div class="min-h-0 flex-1 touch-pan-y overflow-y-auto">
<ul> <ul>
<ChatListItem {#if $chatList}
chat={{ {#each $chatList as chat}
id: 'room-1', <ChatListItem {chat} />
name: 'Blah IM Interest Group', {/each}
lastMessage: { {/if}
sender: { id: '1', name: 'septs' },
content: '窄带通信吧asn1 + bzip2 效果还是可以的',
date: new Date('2024-08-29T02:11Z')
},
unreadCount: 3
}}
/>
<ChatListItem
chat={{
id: 'room-2',
name: 'Laoself Chat',
lastMessage: {
sender: { id: '10', name: 'Richard Luo 🐱' },
content: '如果durov没事 那你是不是又可以拖延症复发了(',
date: new Date('2024-08-29T02:11Z')
}
}}
/>
<ChatListItem
chat={{
id: 'room-3',
name: 'Ubuntu中文',
lastMessage: {
sender: { id: '15', name: 'chen jianhao' },
content:
'就什么也没安装昨晚好好的就打开了pycharm而已写完代码直接按设置好的快捷键alt+s 关机 重启就不行了',
date: new Date('2024-08-29T02:27Z')
},
unreadCount: 827469
}}
/>
<ChatListItem
chat={{
id: '1',
name: 'septs',
lastMessage: {
sender: { id: '1', name: 'septs' },
content: '验证 checksum 是否正确的代价还是可以接受的',
date: new Date('2024-08-28T02:54Z')
}
}}
/>
<ChatListItem
chat={{
id: '2',
name: 'oxa',
lastMessage: {
sender: { id: '2', name: 'oxa' },
content: '但似乎现在大家都讨厌 pgp ,觉得太复杂',
date: new Date('2024-08-28T02:37Z')
}
}}
/>
<ChatListItem
chat={{
id: '3',
name: 'omo',
lastMessage: {
sender: { id: '3', name: 'omo' },
content: '我對 revalidate 的理解是不經過 cache 直接重拉一遍',
date: new Date('2024-08-28T02:11Z')
},
unreadCount: 8
}}
/>
<ChatListItem
chat={{
id: '4',
name: 'Inno Aiolos',
lastMessage: {
sender: { id: '4', name: 'Inno Aiolos' },
content: '至少得把信息分发给所有广播自己是这个public key的destination',
date: new Date('2024-07-28T02:11Z')
}
}}
/>
<ChatListItem
chat={{
id: '5',
name: 'Gary です',
lastMessage: {
sender: { id: '5', name: 'Gary です' },
content: '没必要8长毛象那样挺麻烦的',
date: new Date('2023-07-28T02:11Z')
}
}}
/>
<ChatListItem
chat={{
id: '6',
name: 'Chtholly Nota Seniorious',
lastMessage: {
sender: { id: '6', name: 'Chtholly Nota Seniorious' },
content: '遥遥领先!\n隔壁 nostr 最开始没有注意到这个问题,然后被狂灌置顶 spam',
date: new Date('2022-07-28T02:11Z')
}
}}
/>
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -7,12 +7,21 @@
import { blahRichTextToPlainText } from '$lib/richText'; import { blahRichTextToPlainText } from '$lib/richText';
export let chat: Chat; export let chat: Chat;
let urlSafeEndpoint: string;
$: {
const url = new URL(chat.server);
urlSafeEndpoint = encodeURIComponent(url.hostname + url.pathname);
}
</script> </script>
<li <li
class="relative after:absolute after:bottom-0 after:end-0 after:start-14 after:border-t-[0.5px] after:border-ss-secondary" class="relative after:absolute after:bottom-0 after:end-0 after:start-14 after:border-t-[0.5px] after:border-ss-secondary"
> >
<a href="/chats/{chat.id}" class="flex h-20 cursor-default items-center gap-2 px-2"> <a
href="/chats/{urlSafeEndpoint}/{chat.id}"
class="flex h-20 cursor-default items-center gap-2 px-2"
>
<div class="size-10"> <div class="size-10">
<AvatarBeam size={40} name={chat.name} /> <AvatarBeam size={40} name={chat.name} />
</div> </div>

View file

@ -28,7 +28,7 @@
<div class="flex h-full w-full flex-col items-center justify-center"> <div class="flex h-full w-full flex-col items-center justify-center">
{#if server} {#if server}
{@const { info, messages, sendMessage } = useChat(server, roomId)} {@const { info, messages, sendMessage } = useChat(server, roomId)}
<ChatPage {info} {messages} on:sendMessage={sendMessage} /> <ChatPage {info} {messages} on:sendMessage={(e) => sendMessage(e.detail)} />
{:else} {:else}
<ServiceMessage> <ServiceMessage>
To view this chat, you need to connect to chat server To view this chat, you need to connect to chat server

View file

@ -28,7 +28,7 @@
</script> </script>
<form <form
class="flex items-end gap-2 border-t border-ss-secondary bg-sb-primary p-2 shadow-sm" class="flex w-full items-end gap-2 border-t border-ss-secondary bg-sb-primary p-2 shadow-sm"
bind:this={form} bind:this={form}
on:submit|preventDefault={submit} on:submit|preventDefault={submit}
> >

View file

@ -13,13 +13,13 @@
export let info: Readable<Chat>; export let info: Readable<Chat>;
export let messages: Readable<Message[]>; export let messages: Readable<Message[]>;
type $$Events = { interface $$Events {
sendMessage: BlahRichText; sendMessage: CustomEvent<BlahRichText>;
}; }
</script> </script>
<ChatHeader info={$info} outsideUnreadCount={263723} /> <ChatHeader info={$info} outsideUnreadCount={263723} />
<BgPattern class="flex-1" pattern="charlieBrown"> <BgPattern class="w-full flex-1" pattern="charlieBrown">
<ChatHistory messages={$messages} mySenderId={$currentKeyPair?.id} /> <ChatHistory messages={$messages} mySenderId={$currentKeyPair?.id} />
</BgPattern> </BgPattern>
<ChatInput on:sendMessage /> <ChatInput on:sendMessage />