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 (
{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 (
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}
{t("setting.spaces.description-help")}
{isAdmin ? (
) : null}
{t("setting.spaces.your-access")}
{t("setting.spaces.role-in-space")}
{isAdmin ? t("setting.spaces.admin-role-description") : t("setting.spaces.user-role-description")}
{t("setting.spaces.leave-space")}
{isLastAdmin ? t("setting.spaces.last-admin-leave-description") : t("setting.spaces.leave-description")}
{isAdmin ? (
{t("setting.spaces.delete-space")}
{t("setting.spaces.delete-description")}
) : null}
) : (
{t("setting.spaces.members")}
{t("setting.spaces.members-description")}
{isAdmin ? (
) : null}
{membersQuery.isLoading ? (
{t("setting.spaces.loading-members")}
) : membersQuery.isError ? (
{t("setting.spaces.members-load-error")}
) : (
members.map((member) => {
const isCurrentUser = member.user === viewerName;
return (
{isAdmin && !isCurrentUser ? (
) : (
)}
{isAdmin && !isCurrentUser ? (
) : null}
);
})
)}
{isAdmin ? (
{t("setting.spaces.pending-invitations")}
{invitations.length}
{invitationsQuery.isLoading ? (
{t("setting.spaces.loading-invitations")}
) : invitations.length === 0 ? (
{t("setting.spaces.no-pending-invitations")}
) : (
invitations.map((invitation) => (
{t("setting.spaces.invitation-pending")}
))
)}
) : null}
)}
{t("setting.spaces.leave-confirm-title", { space: disambiguatedSpaceTitle })}
}
description={t("setting.spaces.leave-confirm-description")}
confirmLabel={t("setting.spaces.leave")}
cancelLabel={t("common.cancel")}
onConfirm={handleLeave}
confirmVariant="destructive"
/>
{t("setting.spaces.delete-confirm-title", { space: disambiguatedSpaceTitle })}
}
description={t("setting.spaces.delete-confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDeleteSpace}
confirmVariant="destructive"
/>
!open && setRemoveTarget(undefined)}
title={t("setting.spaces.remove-confirm-title", { username: extractUsernameFromName(removeTarget?.user ?? "") })}
description={t("setting.spaces.remove-confirm-description")}
confirmLabel={t("setting.spaces.remove")}
cancelLabel={t("common.cancel")}
onConfirm={handleRemoveMember}
confirmVariant="destructive"
/>
!open && setCancelInvitationTarget(undefined)}
title={t("setting.spaces.cancel-invitation-confirm-title", {
username: extractUsernameFromName(cancelInvitationTarget?.invitee ?? ""),
})}
description={t("setting.spaces.cancel-invitation-confirm-description")}
confirmLabel={t("setting.spaces.cancel-invitation")}
cancelLabel={t("common.cancel")}
onConfirm={handleCancelInvitation}
confirmVariant="destructive"
/>
);
};
export default SpacesSection;