Files
dumpsterChat/web/src/components/MemberList.tsx
T

204 lines
6.8 KiB
TypeScript

import { useEffect, useState } from "react";
import { useServerStore } from "../stores/server.ts";
import { useAuthStore } from "../stores/auth.ts";
import { useMemberStore, type Member } from "../stores/member.ts";
import { usePresenceStore } from "../stores/presence.ts";
import type { UserStatus } from "../stores/auth.ts";
import { usePermissions } from "../lib/usePermissions.ts";
import { MemberContextMenu } from "./MemberContextMenu.tsx";
import { useConversationStore } from "../stores/conversation.ts";
import { useLayoutStore } from "../stores/layout.ts";
import { useNavigate } from "react-router-dom";
import { UserProfileModal } from "./UserProfileModal.tsx";
function statusIcon(status: UserStatus): string {
switch (status) {
case "online":
return "\u25cf";
case "idle":
return "\u25d0";
case "dnd":
return "\u26d5";
default:
return "\u25cb";
}
}
function statusColor(status: UserStatus): string {
switch (status) {
case "online":
return "text-gb-green";
case "idle":
return "text-gb-yellow";
case "dnd":
return "text-gb-red";
default:
return "text-gb-gray";
}
}
function usernameColor(status: UserStatus): string {
switch (status) {
case "online":
return "text-gb-aqua";
case "idle":
return "text-gb-yellow";
case "dnd":
return "text-gb-red";
default:
return "text-gb-fg-f";
}
}
interface MemberRowProps {
member: Member;
onProfileClick: (userId: string) => void;
}
function MemberRow({ member, onProfileClick }: MemberRowProps) {
const presence = usePresenceStore((s) => s.presences[member.id]);
const status = presence?.status ?? member.status;
const [menuOpen, setMenuOpen] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const activeServerId = useServerStore((s) => s.activeServerId);
const setActiveServer = useServerStore((s) => s.setActiveServer);
const createConversation = useConversationStore((s) => s.createConversation);
const setDM = useLayoutStore((s) => s.setDM);
const navigate = useNavigate();
const { canKick, canBan, canMute } = usePermissions(activeServerId);
const isSelf = currentUser?.id === member.id;
return (
<div className="relative">
<div
onClick={() => onProfileClick(member.id)}
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t cursor-pointer group"
>
<span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)} flex-1`}>
{member.nickname || member.display_name || member.username}
</span>
{!isSelf && (
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="text-gb-fg-f hover:text-gb-orange font-mono text-xs px-1 hover:bg-gb-bg-s rounded shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
title="Member options"
>
[...]
</button>
)}
</div>
{menuOpen && !isSelf && activeServerId && (
<MemberContextMenu
memberId={member.id}
username={member.username}
canKick={canKick}
canBan={canBan}
canMute={canMute}
onMessage={async () => {
try {
const conv = await createConversation([member.id]);
setMenuOpen(false);
setActiveServer(null);
setDM(true);
navigate(`/dm/${conv.id}`);
} catch (err) {
console.error("Failed to start DM:", err);
}
}}
onKick={(_reason) => {
fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" })
.then(() => setMenuOpen(false));
}}
onBan={(reason) => {
fetch(`/api/v1/servers/${activeServerId}/bans`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: member.id, reason }),
}).then(() => setMenuOpen(false));
}}
onMute={(duration, reason) => {
fetch(`/api/v1/servers/${activeServerId}/mutes`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: member.id, duration, reason }),
}).then(() => setMenuOpen(false));
}}
onClose={() => setMenuOpen(false)}
/>
)}
</div>
);
}
export function MemberList() {
const activeServerId = useServerStore((s) => s.activeServerId);
const membersByServer = useMemberStore((s) => s.membersByServer);
const fetchMembers = useMemberStore((s) => s.fetchMembers);
const presences = usePresenceStore((s) => s.presences);
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const members = activeServerId
? membersByServer[activeServerId] || []
: [];
useEffect(() => {
if (activeServerId) {
fetchMembers(activeServerId);
}
}, [activeServerId, fetchMembers]);
const online = members.filter((m) => {
const status = presences[m.id]?.status ?? m.status;
return status !== "offline";
});
const offline = members.filter((m) => {
const status = presences[m.id]?.status ?? m.status;
return status === "offline";
});
return (
<div className="h-full w-52 bg-gb-bg-s border-l border-gb-bg-t flex flex-col">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
[MEMBERS]
</div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{!activeServerId && (
<p className="text-gb-fg-f">[select a server]</p>
)}
{activeServerId && members.length === 0 && (
<p className="text-gb-fg-f">[no members]</p>
)}
{online.length > 0 && (
<div className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{online.map((m) => (
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))}
</div>
)}
{offline.length > 0 && (
<div>
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{offline.map((m) => (
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))}
</div>
)}
</div>
{profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
)}
</div>
);
}