mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-05-01 08:41:08 +00:00
feat: [wip] search panel
This commit is contained in:
parent
1a1ac5cd43
commit
3ee0156d19
12 changed files with 179 additions and 43 deletions
|
@ -3,6 +3,7 @@ export type BlahPayloadSignee<P> = {
|
||||||
payload: P;
|
payload: P;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
user: string;
|
user: string;
|
||||||
|
act_key?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BlahSignedPayload<P> = {
|
export type BlahSignedPayload<P> = {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import type { BlahSignedPayload } from '../crypto';
|
||||||
import type { BlahMessage } from './message';
|
import type { BlahMessage } from './message';
|
||||||
|
|
||||||
export type BlahRoomInfo = {
|
export type BlahRoomInfo = {
|
||||||
ruuid: string;
|
rid: string;
|
||||||
title: string;
|
title: string;
|
||||||
last_chat?: BlahSignedPayload<BlahMessage>;
|
last_chat?: BlahSignedPayload<BlahMessage>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,8 +13,8 @@ export class ChatListManager {
|
||||||
private sortChats(chatList: Chat[]) {
|
private sortChats(chatList: Chat[]) {
|
||||||
chatList.sort(
|
chatList.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.lastMessage?.date ?? new Date(1970, 0, 1)).getTime() ??
|
(b.lastMessage?.date ?? new Date(0)).getTime() ??
|
||||||
-(a.lastMessage?.date ?? new Date(1970, 0, 1)).getTime()
|
-(a.lastMessage?.date ?? new Date(0)).getTime()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ export class ChatListManager {
|
||||||
for (const chat of chats) {
|
for (const chat of chats) {
|
||||||
const newChat = chatFromBlah(chat, serverEndpoint);
|
const newChat = chatFromBlah(chat, serverEndpoint);
|
||||||
|
|
||||||
const existing = chatList.find((c) => c.id === chat.ruuid);
|
const existing = chatList.find((c) => c.id === newChat.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.name = newChat.name;
|
existing.name = newChat.name;
|
||||||
existing.lastMessage = newChat.lastMessage ?? existing.lastMessage;
|
existing.lastMessage = newChat.lastMessage ?? existing.lastMessage;
|
||||||
|
@ -43,7 +43,7 @@ export class ChatListManager {
|
||||||
const chat = chatList.find((c) => c.id === message.signee.payload.room);
|
const chat = chatList.find((c) => c.id === message.signee.payload.room);
|
||||||
if (chat) {
|
if (chat) {
|
||||||
const newChat = chatFromBlah(
|
const newChat = chatFromBlah(
|
||||||
{ ruuid: chat.id, title: chat.name, last_chat: message },
|
{ rid: chat.id, title: chat.name, last_chat: message },
|
||||||
serverEndpoint
|
serverEndpoint
|
||||||
);
|
);
|
||||||
chat.lastMessage = newChat.lastMessage ?? chat.lastMessage;
|
chat.lastMessage = newChat.lastMessage ?? chat.lastMessage;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { BlahKeyPair, type EncodedBlahKeyPair } from './blah/crypto';
|
||||||
import { currentKeyPair } from './keystore';
|
import { currentKeyPair } from './keystore';
|
||||||
import { ChatListManager } from './chatList';
|
import { ChatListManager } from './chatList';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { GlobalSearchManager } from './globalSearch';
|
||||||
|
|
||||||
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']);
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ 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();
|
chatList: ChatListManager = new ChatListManager();
|
||||||
|
searchManager: GlobalSearchManager = new GlobalSearchManager(this.connections);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
|
|
|
@ -5,11 +5,11 @@
|
||||||
export { className as class };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<label
|
||||||
class={tw(
|
class={tw(
|
||||||
'flex items-center gap-1 rounded-md px-2 py-1.5 caret-accent-500 shadow-[inset_0_1px_2px_0_rgb(0_0_0/0.05)] ring-1 ring-ss-secondary',
|
'flex items-center gap-1 rounded-md bg-sb-primary px-2 py-1.5 caret-accent-500 shadow-[inset_0_1px_2px_0_rgb(0_0_0/0.05)] ring-1 ring-ss-secondary transition-shadow duration-200 has-[input,textarea,[contenteditable]]:cursor-text has-[:focus]:ring-ss-primary',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</label>
|
||||||
|
|
56
src/lib/globalSearch.ts
Normal file
56
src/lib/globalSearch.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import type { BlahChatServerConnection } from './blah/connection/chatServer';
|
||||||
|
import { chatFromBlah, type Chat } from './types';
|
||||||
|
|
||||||
|
export class GlobalSearchManager {
|
||||||
|
private connections: Map<string, BlahChatServerConnection>;
|
||||||
|
|
||||||
|
constructor(connections: Map<string, BlahChatServerConnection>) {
|
||||||
|
this.connections = connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async searchChats(query: string): Promise<{ joined: Chat[]; public: Chat[] }> {
|
||||||
|
let jobs: Promise<['joined' | 'public', Chat[]]>[] = [];
|
||||||
|
|
||||||
|
for (const [endpoint, connection] of this.connections.entries()) {
|
||||||
|
const fetchInJoinedRooms = async (): Promise<['joined' | 'public', Chat[]]> => [
|
||||||
|
'joined',
|
||||||
|
(await connection.fetchJoinedRooms()).map((r) => chatFromBlah(r, endpoint))
|
||||||
|
];
|
||||||
|
const fetchInPublicRooms = async (): Promise<['joined' | 'public', Chat[]]> => [
|
||||||
|
'public',
|
||||||
|
(await connection.discoverRooms()).map((r) => chatFromBlah(r, endpoint))
|
||||||
|
];
|
||||||
|
|
||||||
|
jobs = jobs.concat([fetchInJoinedRooms(), fetchInPublicRooms()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(jobs);
|
||||||
|
console.log(results);
|
||||||
|
|
||||||
|
const chats: { joined: Chat[]; public: Chat[] } = { joined: [], public: [] };
|
||||||
|
for (const result of results) {
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
if (result.status === 'rejected') continue;
|
||||||
|
|
||||||
|
const [type, chatList] = result.value;
|
||||||
|
for (const chat of chatList) {
|
||||||
|
if (!chat.name.includes(query)) continue; // TODO: Actual backend search
|
||||||
|
if (chats[type].find((c) => c.id === chat.id)) continue; // Dedupe in its own type
|
||||||
|
if (type !== 'joined' && chats.joined.find((c) => c.id === chat.id)) continue; // If already in joined, don't add to public
|
||||||
|
|
||||||
|
// Insert in last message date order
|
||||||
|
const date = chat.lastMessage?.date;
|
||||||
|
if (!date) {
|
||||||
|
chats[type].push(chat);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let idx = chats[type].findIndex((c) => (c.lastMessage ? c.lastMessage?.date < date : true));
|
||||||
|
if (idx === -1) idx = 0;
|
||||||
|
chats[type].splice(idx, 0, chat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chats;
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ export type Chat = {
|
||||||
export function chatFromBlah(room: BlahRoomInfo, serverEndpoint: string): Chat {
|
export function chatFromBlah(room: BlahRoomInfo, serverEndpoint: string): Chat {
|
||||||
return {
|
return {
|
||||||
server: serverEndpoint,
|
server: serverEndpoint,
|
||||||
id: room.ruuid,
|
id: room.rid,
|
||||||
name: room.title,
|
name: room.title,
|
||||||
type: 'group',
|
type: 'group',
|
||||||
lastMessage: room.last_chat ? messageFromBlah(room.last_chat) : undefined
|
lastMessage: room.last_chat ? messageFromBlah(room.last_chat) : undefined
|
||||||
|
|
|
@ -1,16 +1,21 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { useChatList } from '$lib/chatList';
|
import { useChatList } from '$lib/chatList';
|
||||||
import { chatServerConnectionPool } from '$lib/chatServers';
|
import { chatServerConnectionPool } from '$lib/chatServers';
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
import ChatListHeader from './ChatListHeader.svelte';
|
import ChatListHeader from './ChatListHeader.svelte';
|
||||||
import ChatListItem from './ChatListItem.svelte';
|
import ChatListItem from './ChatListItem.svelte';
|
||||||
|
import SearchPanel from './SearchPanel.svelte';
|
||||||
|
|
||||||
const chatList = browser ? useChatList(chatServerConnectionPool.chatList) : null;
|
const chatList = browser ? useChatList(chatServerConnectionPool.chatList) : null;
|
||||||
|
|
||||||
|
let isSearchFocused: boolean;
|
||||||
|
let searchQuery: string;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-[100dvh] flex-col justify-stretch">
|
<div class="flex h-[100dvh] flex-col justify-stretch">
|
||||||
<ChatListHeader />
|
<ChatListHeader bind:isSearchFocused bind:searchQuery />
|
||||||
<div class="min-h-0 flex-1 touch-pan-y overflow-y-auto">
|
<div class="relative min-h-0 flex-1 touch-pan-y overflow-y-auto">
|
||||||
<ul>
|
<ul>
|
||||||
{#if $chatList}
|
{#if $chatList}
|
||||||
{#each $chatList as chat}
|
{#each $chatList as chat}
|
||||||
|
@ -18,5 +23,13 @@
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
|
{#if isSearchFocused}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 size-full origin-top touch-pan-y overflow-y-auto bg-sb-primary"
|
||||||
|
transition:scale={{ start: 0.9 }}
|
||||||
|
>
|
||||||
|
<SearchPanel {searchQuery} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,45 +1,51 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import InputFrame from '$lib/components/InputFrame.svelte';
|
import InputFrame from '$lib/components/InputFrame.svelte';
|
||||||
|
import { Icon, MagnifyingGlass, PencilSquare, XCircle } from 'svelte-hero-icons';
|
||||||
import IdentityMenu from './IdentityMenu.svelte';
|
import IdentityMenu from './IdentityMenu.svelte';
|
||||||
|
import { tw } from '$lib/tw';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
export let searchQuery: string = '';
|
||||||
|
export let isSearchFocused: boolean;
|
||||||
|
|
||||||
|
let inputElement: HTMLInputElement;
|
||||||
|
|
||||||
|
function onTapX() {
|
||||||
|
searchQuery = '';
|
||||||
|
inputElement.blur();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="flex items-center justify-stretch gap-2 border-b border-ss-secondary p-2 shadow-sm">
|
<header class="flex items-center justify-stretch gap-2 border-b border-ss-secondary p-2 shadow-sm">
|
||||||
<IdentityMenu />
|
<IdentityMenu class={tw('transition-opacity duration-200', isSearchFocused && 'opacity-0')} />
|
||||||
<InputFrame class="flex-1">
|
<InputFrame class={tw('z-10 flex-1 transition-all duration-200', isSearchFocused && '-mx-10')}>
|
||||||
<svg
|
<Icon src={MagnifyingGlass} class="size-5 text-slate-400" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
class="size-5 text-slate-400"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
class="w-full flex-1 bg-transparent text-sm leading-4 text-slate-900 focus:outline-none"
|
class="w-full flex-1 bg-transparent text-sm leading-4 text-slate-900 focus:outline-none"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
bind:this={inputElement}
|
||||||
|
on:focus={() => (isSearchFocused = true)}
|
||||||
|
on:blur={async () => {
|
||||||
|
await tick();
|
||||||
|
isSearchFocused = false;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</InputFrame>
|
<button
|
||||||
<Button class="size-8">
|
class={tw(
|
||||||
<svg
|
'size-4 cursor-default text-slate-300 opacity-0 transition-opacity dark:text-slate-500',
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
isSearchFocused && 'opacity-100'
|
||||||
fill="none"
|
)}
|
||||||
viewBox="0 0 24 24"
|
on:click={onTapX}
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="size-5"
|
|
||||||
>
|
>
|
||||||
<path
|
<Icon src={XCircle} mini />
|
||||||
stroke-linecap="round"
|
<span class="sr-only">Clear</span>
|
||||||
stroke-linejoin="round"
|
</button>
|
||||||
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
|
</InputFrame>
|
||||||
/>
|
<Button class={tw('size-8 transition-opacity duration-200', isSearchFocused && 'opacity-0')}>
|
||||||
</svg>
|
<Icon src={PencilSquare} class="size-5" />
|
||||||
<span class="sr-only">Compose</span>
|
<span class="sr-only">Compose</span>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
import { keyStore, currentKeyIndex, currentKeyPair } from '$lib/keystore';
|
import { keyStore, currentKeyIndex, currentKeyPair } from '$lib/keystore';
|
||||||
import { BlahKeyPair, generateName } from '$lib/blah/crypto';
|
import { BlahKeyPair, generateName } from '$lib/blah/crypto';
|
||||||
|
|
||||||
|
let className: string = '';
|
||||||
|
export { className as class };
|
||||||
|
|
||||||
let currentKeyId: string | undefined;
|
let currentKeyId: string | undefined;
|
||||||
let currentKeyName: string | null;
|
let currentKeyName: string | null;
|
||||||
$: {
|
$: {
|
||||||
|
@ -24,10 +27,10 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DropdownMenu.Root closeOnItemClick={false}>
|
<DropdownMenu.Root closeOnItemClick={false}>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger class={className}>
|
||||||
{#if currentKeyId}
|
{#if currentKeyId}
|
||||||
{#key currentKeyId}
|
{#key currentKeyId}
|
||||||
<AvatarBeam size={30} name={currentKeyId} />
|
<AvatarBeam size={32} name={currentKeyId} />
|
||||||
{/key}
|
{/key}
|
||||||
<span class="sr-only">Using identity {currentKeyName}</span>
|
<span class="sr-only">Using identity {currentKeyName}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
21
src/routes/(app)/SearchChatResultSection.svelte
Normal file
21
src/routes/(app)/SearchChatResultSection.svelte
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Chat } from '$lib/types';
|
||||||
|
|
||||||
|
import ChatListItem from './ChatListItem.svelte';
|
||||||
|
|
||||||
|
export let name: string;
|
||||||
|
export let results: Chat[];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<h3
|
||||||
|
class="ms-2 border-b-[0.5px] border-ss-secondary pb-1 pe-2 pt-2 text-xs font-semibold uppercase text-sf-secondary"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
<ul>
|
||||||
|
{#each results as chat}
|
||||||
|
<ChatListItem {chat} />
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
34
src/routes/(app)/SearchPanel.svelte
Normal file
34
src/routes/(app)/SearchPanel.svelte
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { chatServerConnectionPool } from '$lib/chatServers';
|
||||||
|
import { ChatBubbleLeftRight, Icon } from 'svelte-hero-icons';
|
||||||
|
import ChatListItem from './ChatListItem.svelte';
|
||||||
|
import SearchChatResultSection from './SearchChatResultSection.svelte';
|
||||||
|
|
||||||
|
export let searchQuery: string;
|
||||||
|
|
||||||
|
async function search(query: string) {
|
||||||
|
const results = await chatServerConnectionPool.searchManager.searchChats(query);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await search(searchQuery)}
|
||||||
|
<div class="flex size-full items-center justify-center">
|
||||||
|
<Icon src={ChatBubbleLeftRight} solid class="w-1/3 animate-pulse fill-sf-tertiary" />
|
||||||
|
</div>
|
||||||
|
{:then results}
|
||||||
|
{#if results.joined.length === 0 && results.public.length === 0}
|
||||||
|
<div class="flex size-full items-center justify-center">
|
||||||
|
<p class="text-sf-tertiary">No results found</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#if results.joined.length > 0}
|
||||||
|
<SearchChatResultSection name="Recents" results={results.joined} />
|
||||||
|
{/if}
|
||||||
|
{#if results.public.length > 0}
|
||||||
|
<SearchChatResultSection name="Discover" results={results.public} />
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
Loading…
Add table
Reference in a new issue