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);
}
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' };
@ -153,7 +170,7 @@ export class BlahChatServerConnection {
}
connect() {
if (!this.webSocket) this.webSocket = this.createWebSocket();
if (!this.webSocket && this.keypair) this.webSocket = this.createWebSocket();
}
disconnect() {
@ -174,8 +191,6 @@ export class BlahChatServerConnection {
roomId: string,
onNewMessage: (message: BlahSignedPayload<BlahMessage>) => 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();
listeners.add(onNewMessage);
this.roomListeners.set(roomId, listeners);
@ -194,9 +209,6 @@ export class BlahChatServerConnection {
subscribe(onNewMessage: (message: BlahSignedPayload<BlahMessage>) => void): {
unsubscribe: () => void;
} {
if (!this.webSocket)
throw new Error('Must connect to WebSocket before subscribing to messages');
this.serverListeners.add(onNewMessage);
return {

View file

@ -1,3 +1,8 @@
import type { BlahSignedPayload } from '../crypto';
import type { BlahMessage } from './message';
export type BlahRoomInfo = {
ruuid: 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 { BlahKeyPair, type EncodedBlahKeyPair } from './blah/crypto';
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']);
class ChatServerConnectionPool {
private connections: Map<string, BlahChatServerConnection> = new Map();
private keypair: BlahKeyPair | null = null;
chatList: ChatListManager = new ChatListManager();
constructor() {
if (browser) {
chatServers.subscribe(this.onChatServersChange.bind(this));
currentKeyPair.subscribe(this.onKeyPairChange.bind(this));
}
}
connectAll(keypair?: BlahKeyPair) {
for (const endpoint of get(chatServers)) {
const connection = new BlahChatServerConnection(endpoint, keypair);
private createAndConnect(endpoint: string) {
const connection = new BlahChatServerConnection(endpoint, this.keypair);
this.connections.set(endpoint, connection);
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() {
@ -33,24 +54,27 @@ class ChatServerConnectionPool {
this.keypair = await BlahKeyPair.fromEncoded(encodedKeyPair);
for (const connection of this.connections.values()) {
connection.changeKeyPair(this.keypair);
connection.connect();
this.fetchJoinedRooms(connection);
}
}
private async onChatServersChange(newChatServers: string[]) {
// Disconnect from chat servers that are no longer in the list
const disconnectedEndpoints: string[] = [];
for (const [endpoint, connection] of this.connections.entries()) {
if (!newChatServers.includes(endpoint)) {
connection.disconnect();
this.connections.delete(endpoint);
disconnectedEndpoints.push(endpoint);
}
}
this.chatList.leaveChatServers(disconnectedEndpoints);
// Connect to chat servers that are in the list but not yet connected
for (const endpoint of newChatServers) {
if (!this.connections.has(endpoint)) {
const connection = new BlahChatServerConnection(endpoint, this.keypair);
this.connections.set(endpoint, connection);
connection.connect();
this.createAndConnect(endpoint);
}
}
}

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 = {
server: string;
@ -9,3 +10,13 @@ export type Chat = {
lastMessage?: Message;
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>
import { browser } from '$app/environment';
import { useChatList } from '$lib/chatList';
import { chatServerConnectionPool } from '$lib/chatServers';
import ChatListHeader from './ChatListHeader.svelte';
import ChatListItem from './ChatListItem.svelte';
const chatList = browser ? useChatList(chatServerConnectionPool.chatList) : null;
</script>
<div class="flex h-[100dvh] flex-col justify-stretch">
<ChatListHeader />
<div class="min-h-0 flex-1 touch-pan-y overflow-y-auto">
<ul>
<ChatListItem
chat={{
id: 'room-1',
name: 'Blah IM Interest Group',
lastMessage: {
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')
}
}}
/>
{#if $chatList}
{#each $chatList as chat}
<ChatListItem {chat} />
{/each}
{/if}
</ul>
</div>
</div>

View file

@ -7,12 +7,21 @@
import { blahRichTextToPlainText } from '$lib/richText';
export let chat: Chat;
let urlSafeEndpoint: string;
$: {
const url = new URL(chat.server);
urlSafeEndpoint = encodeURIComponent(url.hostname + url.pathname);
}
</script>
<li
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">
<AvatarBeam size={40} name={chat.name} />
</div>

View file

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

View file

@ -28,7 +28,7 @@
</script>
<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}
on:submit|preventDefault={submit}
>

View file

@ -13,13 +13,13 @@
export let info: Readable<Chat>;
export let messages: Readable<Message[]>;
type $$Events = {
sendMessage: BlahRichText;
};
interface $$Events {
sendMessage: CustomEvent<BlahRichText>;
}
</script>
<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} />
</BgPattern>
<ChatInput on:sendMessage />