diff --git a/web/src/lib/users.ts b/web/src/lib/users.ts new file mode 100644 index 0000000..886ee51 --- /dev/null +++ b/web/src/lib/users.ts @@ -0,0 +1,50 @@ +import { useQuery } from "@tanstack/react-query" + +import { api } from "@/lib/api" + +export type UserOption = { + id: string + entity_id: string + username: string + first_name: string + last_name: string + email: string + avatar_url: string +} + +export function useUsers({ enabled = true }: { enabled?: boolean } = {}) { + return useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await api.get("/users") + return res.data + }, + staleTime: 5 * 60 * 1000, + enabled, + }) +} + +export function userName(user: UserOption) { + const fullName = `${user.first_name} ${user.last_name}`.trim() + return fullName || user.username || user.entity_id +} + +export function userInitials(user: UserOption) { + return userName(user) + .split(/\s+/) + .map((part) => part[0]) + .filter(Boolean) + .slice(0, 2) + .join("") + .toUpperCase() +} + +export function userSearchKeys(user: UserOption) { + return [ + user.first_name, + user.last_name, + user.username, + user.email, + user.entity_id, + ].filter(Boolean) +} diff --git a/web/src/pages/groups/AddGroupPersonDialog.tsx b/web/src/pages/groups/AddGroupPersonDialog.tsx index e0cd7f6..a692234 100644 --- a/web/src/pages/groups/AddGroupPersonDialog.tsx +++ b/web/src/pages/groups/AddGroupPersonDialog.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useMutation, useQueryClient } from "@tanstack/react-query" import { Crown, Search, UserPlus } from "lucide-react" import { useMemo, useState } from "react" import { toast } from "sonner" @@ -33,46 +33,12 @@ import { type DurationUnit, } from "@/lib/duration" import { fuzzyFilter } from "@/lib/fuzzy" +import { useUsers, userInitials, userName, userSearchKeys } from "@/lib/users" type AddGroupPersonMode = "member" | "owner" const ENTITY_ID_PATTERN = /^ent_[0-9a-hjkmnp-tv-z]{26}$/i -type UserOption = { - id: string - entity_id: string - username: string - first_name: string - last_name: string - email: string - avatar_url: string -} - -function userName(user: UserOption) { - const fullName = `${user.first_name} ${user.last_name}`.trim() - return fullName || user.username || user.entity_id -} - -function userInitials(user: UserOption) { - return userName(user) - .split(/\s+/) - .map((part) => part[0]) - .filter(Boolean) - .slice(0, 2) - .join("") - .toUpperCase() -} - -function selectableText(user: UserOption) { - return [ - user.first_name, - user.last_name, - user.username, - user.email, - user.entity_id, - ].filter(Boolean) -} - function durationEnd( mode: DurationPreset | "custom", customAmount: number, @@ -109,15 +75,7 @@ export function AddGroupPersonDialog({ const [customAmount, setCustomAmount] = useState(7) const [customUnit, setCustomUnit] = useState("days") - const usersQuery = useQuery({ - queryKey: ["users"], - queryFn: async () => { - const res = await api.get("/users") - return res.data - }, - staleTime: 5 * 60 * 1000, - enabled: open, - }) + const usersQuery = useUsers({ enabled: open }) const existing = useMemo( () => new Set(mode === "member" ? existingMemberEntityIDs : existingOwnerEntityIDs), @@ -135,7 +93,7 @@ export function AddGroupPersonDialog({ const filteredUsers = useMemo(() => { const needle = search.trim() if (!needle) return eligibleUsers.slice(0, 8) - return fuzzyFilter(eligibleUsers, needle, selectableText).slice(0, 8) + return fuzzyFilter(eligibleUsers, needle, userSearchKeys).slice(0, 8) }, [eligibleUsers, search]) const typedEntityID = search.trim() diff --git a/web/src/pages/groups/GroupDetailsPage.tsx b/web/src/pages/groups/GroupDetailsPage.tsx index 44e405b..7d90302 100644 --- a/web/src/pages/groups/GroupDetailsPage.tsx +++ b/web/src/pages/groups/GroupDetailsPage.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner" import { EntityChip } from "@/components/EntityChip" import { PageContainer } from "@/components/PageContainer" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -76,12 +77,17 @@ import { type GroupOwner, type GroupSource, } from "@/lib/groups" +import { + useUsers, + userInitials, + userName, + userSearchKeys, + type UserOption, +} from "@/lib/users" import { AddGroupPersonDialog } from "./AddGroupPersonDialog" import { ReviewRequestDialog } from "./ReviewRequestDialog" -const MEMBER_PREVIEW_COUNT = 6 - function formatDate(iso: string) { if (!iso) return "—" return new Date(iso).toLocaleDateString(undefined, { @@ -149,10 +155,37 @@ function OutlinePill({ ) } -function MemberRow({ member }: { member: GroupMember }) { +function MemberRow({ + member, + user, +}: { + member: GroupMember + user?: UserOption +}) { + const name = user ? userName(user) : "" + return (
- + {user ? ( +
+ + {user.avatar_url && } + + {userInitials(user) || name.slice(0, 1).toUpperCase()} + + +
+ {name} + {user.username && ( + + @{user.username} + + )} +
+
+ ) : ( + + )}
{member.source && } {member.has_expiration ? ( @@ -364,6 +397,7 @@ export default function GroupDetailsPage() { }, enabled: !!id, }) + const usersQuery = useUsers({ enabled: !!id }) const ownersQuery = useQuery({ queryKey: ["group", id, "owners"], @@ -411,6 +445,23 @@ export default function GroupDetailsPage() { return m }, [allGroupsQuery.data]) + const members = useMemo(() => membersQuery.data ?? [], [membersQuery.data]) + const usersByEntityID = useMemo(() => { + const map = new Map() + for (const user of usersQuery.data ?? []) { + if (user.entity_id) map.set(user.entity_id, user) + } + return map + }, [usersQuery.data]) + const needle = memberSearch.trim() + const searching = needle.length > 0 + const visibleMembers = useMemo(() => { + if (!needle) return members + return fuzzyFilter(members, needle, (member) => { + const user = usersByEntityID.get(member.entity_id) + return user ? userSearchKeys(user) : [member.entity_id] + }) + }, [members, needle, usersByEntityID]) async function submitJoinRequest() { if (!id || !myEntityID || submittingJoin) return @@ -509,7 +560,6 @@ export default function GroupDetailsPage() { } const group = groupQuery.data - const members = membersQuery.data ?? [] const owners = ownersQuery.data ?? [] const requests = requestsQuery.data ?? [] const apps = appsQuery.data ?? [] @@ -554,18 +604,6 @@ export default function GroupDetailsPage() { return rows })() - const needle = memberSearch.trim() - const searching = needle.length > 0 - const matchedMembers = searching - ? fuzzyFilter(members, needle, (m) => [m.entity_id]) - : members - const visibleMembers = searching - ? matchedMembers - : matchedMembers.slice(0, MEMBER_PREVIEW_COUNT) - const remainingMembers = searching - ? 0 - : Math.max(0, members.length - visibleMembers.length) - const directCount = members.filter((m) => m.source === "DIRECT").length const syncedCount = members.filter((m) => m.source === "DISCORD" || m.source === "CONDITIONAL").length const directMembershipsEnabled = group.allowed_sources?.includes("DIRECT") ?? false @@ -949,6 +987,7 @@ export default function GroupDetailsPage() { Members + {searching && `${visibleMembers.length} matching · `} {members.length} total · {directCount} direct · {syncedCount} synced {isOwner && ( @@ -993,16 +1032,14 @@ export default function GroupDetailsPage() {
    {visibleMembers.map((m) => (
  • - +
  • ))}
)} - {remainingMembers > 0 && ( -

- + {remainingMembers} more not shown. -

- )}