Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions web/src/lib/users.ts
Original file line number Diff line number Diff line change
@@ -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<UserOption[]>("/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)
}
50 changes: 4 additions & 46 deletions web/src/pages/groups/AddGroupPersonDialog.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -109,15 +75,7 @@ export function AddGroupPersonDialog({
const [customAmount, setCustomAmount] = useState(7)
const [customUnit, setCustomUnit] = useState<DurationUnit>("days")

const usersQuery = useQuery({
queryKey: ["users"],
queryFn: async () => {
const res = await api.get<UserOption[]>("/users")
return res.data
},
staleTime: 5 * 60 * 1000,
enabled: open,
})
const usersQuery = useUsers({ enabled: open })

const existing = useMemo(
() => new Set(mode === "member" ? existingMemberEntityIDs : existingOwnerEntityIDs),
Expand All @@ -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()
Expand Down
83 changes: 60 additions & 23 deletions web/src/pages/groups/GroupDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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 (
<div className="flex items-center justify-between gap-3 py-2.5">
<EntityChip entityId={member.entity_id} />
{user ? (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar className="size-8">
{user.avatar_url && <AvatarImage src={user.avatar_url} alt={name} />}
<AvatarFallback className="text-xs">
{userInitials(user) || name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col leading-tight">
<span className="truncate text-sm">{name}</span>
{user.username && (
<span className="truncate text-xs text-muted-foreground">
@{user.username}
</span>
)}
</div>
</div>
) : (
<EntityChip entityId={member.entity_id} />
)}
<div className="flex shrink-0 items-center gap-2">
{member.source && <SourcePill source={member.source as GroupSource} />}
{member.has_expiration ? (
Expand Down Expand Up @@ -364,6 +397,7 @@ export default function GroupDetailsPage() {
},
enabled: !!id,
})
const usersQuery = useUsers({ enabled: !!id })

const ownersQuery = useQuery({
queryKey: ["group", id, "owners"],
Expand Down Expand Up @@ -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<string, UserOption>()
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
Expand Down Expand Up @@ -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 ?? []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -949,6 +987,7 @@ export default function GroupDetailsPage() {
<CardHeader>
<CardTitle>Members</CardTitle>
<CardDescription>
{searching && `${visibleMembers.length} matching · `}
{members.length} total · {directCount} direct · {syncedCount} synced
</CardDescription>
{isOwner && (
Expand Down Expand Up @@ -993,16 +1032,14 @@ export default function GroupDetailsPage() {
<ul className="divide-y divide-border/60">
{visibleMembers.map((m) => (
<li key={m.entity_id}>
<MemberRow member={m} />
<MemberRow
member={m}
user={usersByEntityID.get(m.entity_id)}
/>
</li>
))}
</ul>
)}
{remainingMembers > 0 && (
<p className="mt-3 text-xs text-muted-foreground">
+ {remainingMembers} more not shown.
</p>
)}
</CardContent>
</Card>
</div>
Expand Down
Loading