279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { Component, createSignal, createMemo, For, Show } from "solid-js";
|
|
import { Portal } from "solid-js/web";
|
|
import {
|
|
X,
|
|
Search,
|
|
UserPlus,
|
|
UserMinus,
|
|
Users,
|
|
Copy,
|
|
Check,
|
|
} from "lucide-solid";
|
|
import Avatar from "../common/Avatar";
|
|
import Button from "../common/Button";
|
|
import Divider from "../common/Divider";
|
|
import {
|
|
knownPeers,
|
|
markAsFriend,
|
|
unmarkAsFriend,
|
|
} from "../../stores/directory";
|
|
import { identity } from "../../stores/identity";
|
|
import { setActiveDM } from "../../stores/dms";
|
|
import { addDMConversation } from "../../stores/dms";
|
|
import * as tauri from "../../lib/tauri";
|
|
|
|
interface UserDirectoryModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
type DirectoryTab = "all" | "friends";
|
|
|
|
const UserDirectoryModal: Component<UserDirectoryModalProps> = (props) => {
|
|
const [searchQuery, setSearchQuery] = createSignal("");
|
|
const [activeTab, setActiveTab] = createSignal<DirectoryTab>("all");
|
|
const [copiedId, setCopiedId] = createSignal<string | null>(null);
|
|
|
|
// filter out our own peer id from the directory
|
|
const filteredPeers = createMemo(() => {
|
|
const myId = identity()?.peer_id;
|
|
const query = searchQuery().toLowerCase().trim();
|
|
const tab = activeTab();
|
|
|
|
let peers = knownPeers();
|
|
|
|
if (tab === "friends") {
|
|
peers = peers.filter((p) => p.is_friend);
|
|
}
|
|
|
|
if (query) {
|
|
peers = peers.filter(
|
|
(p) =>
|
|
p.display_name.toLowerCase().includes(query) ||
|
|
p.peer_id.toLowerCase().includes(query),
|
|
);
|
|
} else {
|
|
// if not searching, hide self from the list to avoid confusion
|
|
peers = peers.filter((p) => p.peer_id !== myId);
|
|
}
|
|
|
|
return peers;
|
|
});
|
|
|
|
async function handleToggleFriend(peerId: string, currentlyFriend: boolean) {
|
|
try {
|
|
if (currentlyFriend) {
|
|
await tauri.removeFriend(peerId);
|
|
unmarkAsFriend(peerId);
|
|
} else {
|
|
await tauri.addFriend(peerId);
|
|
markAsFriend(peerId);
|
|
}
|
|
} catch (e) {
|
|
console.error("failed to toggle friend status:", e);
|
|
}
|
|
}
|
|
|
|
function handleMessagePeer(peerId: string, displayName: string) {
|
|
// start a dm conversation with this peer
|
|
addDMConversation({
|
|
peer_id: peerId,
|
|
display_name: displayName,
|
|
status: "Online",
|
|
unread_count: 0,
|
|
});
|
|
setActiveDM(peerId);
|
|
props.onClose();
|
|
}
|
|
|
|
function handleCopyPeerId(peerId: string) {
|
|
navigator.clipboard.writeText(peerId);
|
|
setCopiedId(peerId);
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Escape") props.onClose();
|
|
}
|
|
|
|
function handleBackdropClick(e: MouseEvent) {
|
|
if (e.target === e.currentTarget) props.onClose();
|
|
}
|
|
|
|
const tabs: { id: DirectoryTab; label: string }[] = [
|
|
{ id: "all", label: "all peers" },
|
|
{ id: "friends", label: "friends" },
|
|
];
|
|
|
|
return (
|
|
<Show when={props.isOpen}>
|
|
<Portal>
|
|
<div
|
|
class="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 animate-fade-in"
|
|
onClick={handleBackdropClick}
|
|
onKeyDown={handleKeydown}
|
|
>
|
|
<div class="bg-gray-900 border-2 border-white/20 w-full max-w-[640px] max-h-[80vh] mx-4 flex flex-col animate-scale-in relative">
|
|
{/* header */}
|
|
<div class="shrink-0 p-6 pb-4">
|
|
<button
|
|
type="button"
|
|
class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center text-white/60 hover:text-white transition-colors duration-200 cursor-pointer"
|
|
onClick={props.onClose}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<Users size={24} class="text-orange" />
|
|
<h2 class="text-[24px] leading-[32px] font-bold text-white">
|
|
user directory
|
|
</h2>
|
|
</div>
|
|
|
|
{/* tabs */}
|
|
<div class="flex items-center gap-1 mb-4">
|
|
<For each={tabs}>
|
|
{(tab) => (
|
|
<button
|
|
type="button"
|
|
class={`px-3 py-1.5 text-[14px] font-medium transition-all duration-200 cursor-pointer ${
|
|
activeTab() === tab.id
|
|
? "bg-gray-800 text-white"
|
|
: "text-white/50 hover:text-white hover:bg-gray-800/50"
|
|
}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
|
|
{/* search */}
|
|
<div class="relative">
|
|
<Search
|
|
size={16}
|
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30"
|
|
/>
|
|
<input
|
|
type="text"
|
|
class="w-full bg-black text-white text-[14px] pl-10 pr-4 py-2.5 outline-none placeholder:text-white/30 border-2 border-white/10 focus:border-orange transition-colors duration-200"
|
|
placeholder="search by name or peer id..."
|
|
value={searchQuery()}
|
|
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider class="mx-6" />
|
|
|
|
{/* peer list */}
|
|
<div class="flex-1 overflow-y-auto p-3">
|
|
<Show
|
|
when={filteredPeers().length > 0}
|
|
fallback={
|
|
<div class="flex flex-col items-center justify-center py-16">
|
|
<Users size={48} class="text-white/10 mb-4" />
|
|
<p class="text-[16px] text-white/30 mb-1">
|
|
{searchQuery()
|
|
? "no peers matching your search"
|
|
: activeTab() === "friends"
|
|
? "no friends added yet"
|
|
: "no peers discovered yet"}
|
|
</p>
|
|
<p class="text-[14px] text-white/20">
|
|
{activeTab() === "friends"
|
|
? "add friends from the all peers tab"
|
|
: "peers will appear as you join communities"}
|
|
</p>
|
|
</div>
|
|
}
|
|
>
|
|
<For each={filteredPeers()}>
|
|
{(peer) => (
|
|
<div class="flex items-center justify-between px-3 py-3 hover:bg-gray-800/50 transition-colors duration-200 group">
|
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
|
<Avatar name={peer.display_name} size="lg" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-[16px] font-medium text-white truncate">
|
|
{peer.display_name}
|
|
</p>
|
|
<Show when={peer.is_friend}>
|
|
<span class="text-[10px] font-mono uppercase tracking-[0.05em] text-orange px-1.5 py-0.5 border border-orange/30 shrink-0">
|
|
friend
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
<Show when={peer.bio}>
|
|
<p class="text-[13px] text-white/40 truncate">
|
|
{peer.bio}
|
|
</p>
|
|
</Show>
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-1 text-[11px] font-mono text-white/20 hover:text-white/40 transition-colors cursor-pointer mt-0.5"
|
|
onClick={() => handleCopyPeerId(peer.peer_id)}
|
|
>
|
|
{peer.peer_id.slice(0, 16)}...
|
|
<Show
|
|
when={copiedId() === peer.peer_id}
|
|
fallback={<Copy size={10} />}
|
|
>
|
|
<Check size={10} class="text-success" />
|
|
</Show>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 shrink-0">
|
|
<button
|
|
type="button"
|
|
class="w-8 h-8 flex items-center justify-center bg-gray-800 text-white/60 hover:text-white transition-colors duration-200 cursor-pointer"
|
|
title={
|
|
peer.is_friend ? "remove friend" : "add friend"
|
|
}
|
|
onClick={() =>
|
|
handleToggleFriend(peer.peer_id, peer.is_friend)
|
|
}
|
|
>
|
|
<Show
|
|
when={peer.is_friend}
|
|
fallback={<UserPlus size={16} />}
|
|
>
|
|
<UserMinus size={16} />
|
|
</Show>
|
|
</button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleMessagePeer(peer.peer_id, peer.display_name)
|
|
}
|
|
>
|
|
message
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* footer */}
|
|
<div class="shrink-0 px-6 py-3 border-t border-white/10">
|
|
<p class="text-[11px] font-mono text-white/20">
|
|
{filteredPeers().length} peer
|
|
{filteredPeers().length !== 1 ? "s" : ""} in directory
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
</Show>
|
|
);
|
|
};
|
|
|
|
export default UserDirectoryModal;
|