import { ArrowLeftIcon, ChevronRightIcon, Clock3Icon, LoaderCircleIcon, LogOutIcon, PlusIcon, ShieldCheckIcon, Trash2Icon, UserPlusIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; import { useNavigate, useSearchParams } from "react-router-dom"; import ConfirmDialog from "@/components/ConfirmDialog"; import CreateSpaceDialog from "@/components/CreateSpaceDialog"; import InviteSpaceMemberDialog from "@/components/Settings/InviteSpaceMemberDialog"; import SettingSection from "@/components/Settings/SettingSection"; import SpaceIconPicker from "@/components/SpaceIconPicker"; import SpaceMark from "@/components/SpaceMark"; import SpaceRoleBadge from "@/components/SpaceRoleBadge"; import UserAvatar from "@/components/UserAvatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import useCurrentUser from "@/hooks/useCurrentUser"; import { useAcceptSpaceInvitation, useDeclineSpaceInvitation, useDeleteSpace, useDeleteSpaceInvitation, useDeleteSpaceMember, useSpaceInvitations, useSpaceMembers, useSpaces, useUpdateSpace, useUpdateSpaceMember, useUserSpaceInvitations, } from "@/hooks/useSpaceQueries"; import { useUsersByUsernames } from "@/hooks/useUserQueries"; import { customIconsEqual } from "@/lib/custom-icons"; import { handleError } from "@/lib/error"; import { extractUsernameFromName } from "@/lib/resource-names"; import { extractSpaceUidFromName } from "@/lib/space-display"; import { ROUTES } from "@/router/routes"; import { type Space, type SpaceInvitation, type SpaceMember, SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; type DetailTab = "general" | "members"; const getSpacesSettingsLocation = (spaceName?: string) => ({ pathname: ROUTES.SETTING, search: spaceName ? `?${new URLSearchParams({ space: spaceName }).toString()}` : "", hash: "#spaces", }); const MemberIdentity = ({ member, user, isCurrentUser }: { member: SpaceMember; user?: User; isCurrentUser?: boolean }) => { const t = useTranslate(); const username = user?.username || extractUsernameFromName(member.user); const displayName = user?.displayName || `@${username}`; return (

{displayName}

{isCurrentUser ? {t("setting.spaces.you")} : null}

@{username}

); }; const InvitationIdentity = ({ invitation, user }: { invitation: SpaceInvitation; user?: User }) => { const username = user?.username || extractUsernameFromName(invitation.invitee); const displayName = user?.displayName || `@${username}`; return (

{displayName}

@{username}

); }; const SpacesSection = () => { const t = useTranslate(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const currentUser = useCurrentUser(); const viewerName = currentUser?.name ?? ""; const managedSpaceName = searchParams.get("space") ?? ""; const spacesQuery = useSpaces(viewerName); const receivedInvitationsQuery = useUserSpaceInvitations(viewerName); const spaces = spacesQuery.data ?? []; const receivedInvitations = receivedInvitationsQuery.data ?? []; const managedSpace = spaces.find((space) => space.name === managedSpaceName); const [createOpen, setCreateOpen] = useState(false); const acceptInvitation = useAcceptSpaceInvitation(viewerName); const declineInvitation = useDeclineSpaceInvitation(viewerName); useEffect(() => { if (managedSpaceName && spacesQuery.isSuccess && !managedSpace) { navigate(getSpacesSettingsLocation(), { replace: true }); } }, [managedSpace, managedSpaceName, navigate, spacesQuery.isSuccess]); const handleOpenSpace = (spaceName: string) => { navigate(getSpacesSettingsLocation(spaceName)); window.scrollTo({ top: 0, behavior: "smooth" }); }; const handleAcceptInvitation = async (invitation: SpaceInvitation) => { try { await acceptInvitation.mutateAsync({ name: invitation.name }); const spaceLabel = invitation.space ? `${invitation.space.title} (${extractSpaceUidFromName(invitation.space.name)})` : t("setting.spaces.label"); toast.success(t("setting.spaces.accept-success", { space: spaceLabel })); } catch (error) { handleError(error, toast.error, { context: "Accept space invitation" }); } }; const handleDeclineInvitation = async (invitation: SpaceInvitation) => { try { await declineInvitation.mutateAsync({ name: invitation.name }); toast.success(t("setting.spaces.decline-success")); } catch (error) { handleError(error, toast.error, { context: "Decline space invitation" }); } }; if (managedSpaceName && !managedSpace && spacesQuery.isLoading) { return (
{t("setting.spaces.loading")}
); } return managedSpace ? ( navigate(getSpacesSettingsLocation())} /> ) : ( <> setCreateOpen(true)}> {t("setting.spaces.create-space")} } className="gap-5" >
{receivedInvitations.length > 0 ? (

{t("setting.spaces.invitations")}

{t("setting.spaces.pending-count", { count: receivedInvitations.length })}
{receivedInvitations.map((invitation) => { const uid = invitation.space ? extractSpaceUidFromName(invitation.space.name) : ""; const title = invitation.space?.title || t("setting.spaces.untitled"); return (

{title}

{uid ? (

{t("space.custom-id-label")}: {uid}

) : null}

{invitation.space?.description || t("setting.spaces.invited-to-join")}

); })}
) : null}

{t("setting.spaces.your-spaces")}

{spaces.length > 0 ? ( {spaces.length === 1 ? t("setting.spaces.space-count", { count: spaces.length }) : t("setting.spaces.spaces-count", { count: spaces.length })} ) : null}
{spacesQuery.isLoading ? (
{t("setting.spaces.loading")}
) : spacesQuery.isError ? (
{t("setting.spaces.load-error")}
) : spaces.length === 0 ? (

{t("setting.spaces.no-spaces")}

{t("setting.spaces.no-spaces-description")}

) : (
{spaces.map((space) => { const uid = extractSpaceUidFromName(space.name); const manageLabel = t("setting.spaces.manage-space", { space: space.title }); return ( ); })}
)}
navigate(getSpacesSettingsLocation(space.name))} note={t("setting.spaces.create-scope-note")} /> ); }; interface SpaceDetailProps { space: Space; viewerName: string; onBack: () => void; } const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => { const t = useTranslate(); const membersQuery = useSpaceMembers(viewerName, space.name); const members = membersQuery.data ?? []; const currentMember = members.find((member) => member.user === viewerName); const currentRole = currentMember?.role ?? space.currentUserRole; const isAdmin = currentRole === SpaceMember_Role.ADMIN; const invitationsQuery = useSpaceInvitations(viewerName, space.name, { enabled: isAdmin }); const invitations = invitationsQuery.data ?? []; const usernames = useMemo( () => Array.from( new Set( [...members.map((member) => member.user), ...invitations.map((invitation) => invitation.invitee)].map(extractUsernameFromName), ), ), [invitations, members], ); const usersQuery = useUsersByUsernames(usernames); const usersByUsername = usersQuery.data ?? new Map(); const [tab, setTab] = useState("general"); const [title, setTitle] = useState(space.title); const [description, setDescription] = useState(space.description); const [icon, setIcon] = useState(space.icon); const [inviteOpen, setInviteOpen] = useState(false); const [leaveOpen, setLeaveOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [removeTarget, setRemoveTarget] = useState(); const [cancelInvitationTarget, setCancelInvitationTarget] = useState(); const updateSpace = useUpdateSpace(viewerName); const deleteSpace = useDeleteSpace(viewerName); const updateMember = useUpdateSpaceMember(viewerName); const deleteMember = useDeleteSpaceMember(viewerName); const deleteInvitation = useDeleteSpaceInvitation(viewerName); const activeAdminCount = members.filter((member) => member.role === SpaceMember_Role.ADMIN).length; const isLastAdmin = currentRole === SpaceMember_Role.ADMIN && activeAdminCount === 1; const memberUserNames = useMemo(() => new Set(members.map((member) => member.user)), [members]); const pendingInviteeNames = useMemo(() => new Set(invitations.map((invitation) => invitation.invitee)), [invitations]); const roleOptions = [ { value: String(SpaceMember_Role.USER), label: t("setting.spaces.space-user") }, { value: String(SpaceMember_Role.ADMIN), label: t("setting.spaces.space-admin") }, ]; const iconChanged = !customIconsEqual(icon, space.icon); const detailsChanged = title.trim() !== space.title || description.trim() !== space.description || iconChanged; const spaceUid = extractSpaceUidFromName(space.name); const disambiguatedSpaceTitle = `${space.title} (${spaceUid})`; useEffect(() => { setTitle(space.title); setDescription(space.description); setIcon(space.icon); }, [space.description, space.title, space.icon]); const handleSave = async () => { const trimmedTitle = title.trim(); if (!trimmedTitle || !isAdmin || updateSpace.isPending) return; try { await updateSpace.mutateAsync({ space: { name: space.name, title: trimmedTitle, description: description.trim(), ...(iconChanged ? { icon } : {}) }, updateMask: ["title", "description", ...(iconChanged ? ["icon"] : [])], }); toast.success(t("setting.spaces.save-success")); } catch (error) { handleError(error, toast.error, { context: "Update space" }); } }; const handleRoleChange = async (member: SpaceMember, role: SpaceMember_Role) => { if (member.role === role || updateMember.isPending) return; try { await updateMember.mutateAsync({ spaceMember: { name: member.name, role }, updateMask: ["role"] }); toast.success(t("setting.spaces.role-update-success")); } catch (error) { handleError(error, toast.error, { context: "Update space member" }); } }; const handleRemoveMember = async () => { if (!removeTarget) return; try { await deleteMember.mutateAsync({ name: removeTarget.name }); toast.success(t("setting.spaces.remove-success", { username: extractUsernameFromName(removeTarget.user) })); setRemoveTarget(undefined); } catch (error) { handleError(error, toast.error, { context: "Remove space member" }); throw error; } }; const handleCancelInvitation = async () => { if (!cancelInvitationTarget) return; try { await deleteInvitation.mutateAsync({ name: cancelInvitationTarget.name }); toast.success(t("setting.spaces.cancel-invitation-success")); setCancelInvitationTarget(undefined); } catch (error) { handleError(error, toast.error, { context: "Cancel space invitation" }); throw error; } }; const handleLeave = async () => { if (!currentMember) return; try { await deleteMember.mutateAsync({ name: currentMember.name }); toast.success(t("setting.spaces.leave-success", { space: disambiguatedSpaceTitle })); onBack(); } catch (error) { handleError(error, toast.error, { context: "Leave space" }); throw error; } }; const handleDeleteSpace = async () => { try { await deleteSpace.mutateAsync({ name: space.name }); toast.success(t("setting.spaces.delete-success", { space: disambiguatedSpaceTitle })); onBack(); } catch (error) { handleError(error, toast.error, { context: "Delete space" }); throw error; } }; return (

{space.title}

{t("space.custom-id-label")}: {spaceUid}

{space.description || t("setting.spaces.no-description")}

setTab(value as DetailTab)} variant="underline" className="mt-2"> {t("setting.spaces.general")} {t("setting.spaces.members")} {members.length || space.memberCount} {tab === "general" ? (

{t("setting.spaces.space-details")}

{t("setting.spaces.space-details-description")}

{t("space.custom-id-label")} {spaceUid}
{isAdmin ? ( ) : ( )} setTitle(event.target.value)} readOnly={!isAdmin} className={!isAdmin ? "bg-muted/25" : undefined} />

{t("setting.spaces.description-help")}