172 lines
5.6 KiB
TypeScript
172 lines
5.6 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 { MemberContextMenu } from "./MemberContextMenu.tsx";
|
|
import { useConversationStore } from "../stores/conversation.ts";
|
|
|
|
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";
|
|
}
|
|
}
|
|
|
|
function MemberRow({ member }: { member: Member }) {
|
|
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 userPerms = useServerStore((s) => s.userPermissions);
|
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
|
const createConversation = useConversationStore((s) => s.createConversation);
|
|
|
|
const canKick = userPerms?.kick_members ?? false;
|
|
const canBan = userPerms?.ban_members ?? false;
|
|
const canMute = userPerms?.mute_members ?? false;
|
|
const isSelf = currentUser?.id === member.id;
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setMenuOpen((v) => !v)}
|
|
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t"
|
|
>
|
|
<span className={statusColor(status)}>{statusIcon(status)}</span>
|
|
<span className={`truncate ${usernameColor(status)}`}>
|
|
{member.display_name || member.username}
|
|
</span>
|
|
<span className="ml-auto text-gb-fg-f text-xs">[...]</span>
|
|
</button>
|
|
{menuOpen && !isSelf && activeServerId && (
|
|
<MemberContextMenu
|
|
memberId={member.id}
|
|
username={member.username}
|
|
canKick={canKick}
|
|
canBan={canBan}
|
|
canMute={canMute}
|
|
onMessage={() => {
|
|
createConversation([member.id]);
|
|
setMenuOpen(false);
|
|
}}
|
|
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 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} />
|
|
))}
|
|
</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} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|