mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-05-01 08:41:08 +00:00
feat: chat list (joined)
This commit is contained in:
parent
48c5ed4687
commit
9e899bbb27
10 changed files with 162 additions and 126 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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
68
src/lib/chatList.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
Loading…
Add table
Reference in a new issue