From 24c5259056a854d2d767b012c69d4dbbcacaf8bb Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 31 Aug 2026 12:27:20 -0400 Subject: [PATCH] feat(awards): add awards listing page, grant permissions, and profile ribbon rack integration --- src/app/(frontend)/awards/actions.ts | 159 +++++++++++ src/app/(frontend)/awards/grant-context.ts | 105 +++++++ src/app/(frontend)/awards/page.tsx | 86 ++++++ .../(frontend)/profile/[username]/page.tsx | 30 +- .../frontend/account/AwardListingCard.tsx | 132 +++++++++ .../frontend/account/AwardPickerDialog.tsx | 120 ++++++++ .../frontend/account/AwardWithGrantButton.tsx | 59 ++++ .../frontend/account/GrantAwardDialog.tsx | 263 ++++++++++++++++++ .../frontend/account/MedalViewer.tsx | 214 ++++++++++++++ .../account/ProfileGrantAwardButton.tsx | 49 ++++ .../frontend/account/RecipientPicker.tsx | 254 +++++++++++++++++ .../frontend/account/RibbonRack.tsx | 4 +- src/components/frontend/blocks/AppSidebar.tsx | 6 + src/lib/awards/grant.ts | 178 ++++++++++++ src/utils/event-log/eventTypes.ts | 3 + 15 files changed, 1653 insertions(+), 9 deletions(-) create mode 100644 src/app/(frontend)/awards/actions.ts create mode 100644 src/app/(frontend)/awards/grant-context.ts create mode 100644 src/app/(frontend)/awards/page.tsx create mode 100644 src/components/frontend/account/AwardListingCard.tsx create mode 100644 src/components/frontend/account/AwardPickerDialog.tsx create mode 100644 src/components/frontend/account/AwardWithGrantButton.tsx create mode 100644 src/components/frontend/account/GrantAwardDialog.tsx create mode 100644 src/components/frontend/account/MedalViewer.tsx create mode 100644 src/components/frontend/account/ProfileGrantAwardButton.tsx create mode 100644 src/components/frontend/account/RecipientPicker.tsx create mode 100644 src/lib/awards/grant.ts diff --git a/src/app/(frontend)/awards/actions.ts b/src/app/(frontend)/awards/actions.ts new file mode 100644 index 0000000..b40a013 --- /dev/null +++ b/src/app/(frontend)/awards/actions.ts @@ -0,0 +1,159 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { isSuperuser } from "@/utils/access-control/hasPermission"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import { + countGrantsByGrantor, + findUserProfile, + getGrantorAssignments, + isRecipientEligible, +} from "@/lib/awards/grant"; +import type { Profile } from "@/payload-types"; + +interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ + headers: hdrs, + canSetHeaders: false, + }); + + if (!isPayloadUser(user)) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +export async function grantAward(input: { + recipientUserId: number; + awardId: number; + reason?: string; +}): Promise { + try { + const { payload, user } = await authenticate(); + + let award; + try { + award = await payload.findByID({ + collection: "awards", + id: input.awardId, + depth: 0, + overrideAccess: true, + }); + } catch { + return { success: false, error: "Award not found." }; + } + + let grantorProfile: Profile | null = null; + const superuser = await isSuperuser(payload, user); + + if (award.type === "medal") { + if (!superuser) { + return { success: false, error: "Only superusers can grant medals." }; + } + } else if (!superuser) { + const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? []; + if (allowedTypes.length === 0) { + return { success: false, error: "This award cannot be granted by leaders." }; + } + + const grantorAssignments = await getGrantorAssignments( + payload, + user.id as number, + allowedTypes, + ); + if (grantorAssignments.length === 0) { + return { success: false, error: "You are not authorized to grant this award." }; + } + const grantorAssignmentId = grantorAssignments[0].id; + + const maxGrants = award.grantConfig?.maxGrantsPerGrantor ?? null; + if (maxGrants) { + grantorProfile = await findUserProfile(payload, user.id as number); + if (!grantorProfile) { + return { success: false, error: "You do not have a profile." }; + } + const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id); + if (issued >= maxGrants) { + return { + success: false, + error: "You have reached the maximum number of grants for this award.", + }; + } + } + + const eligible = await isRecipientEligible( + payload, + input.recipientUserId, + award.grantConfig?.recipientScope ?? "same_command", + grantorAssignmentId, + award.grantConfig?.eligibleRecipientAssignments, + ); + if (!eligible) { + return { success: false, error: "Recipient is not eligible for this award." }; + } + } + + const recipientProfile = await findUserProfile(payload, input.recipientUserId); + if (!recipientProfile) { + return { success: false, error: "Recipient does not have a profile." }; + } + + if (!grantorProfile) { + grantorProfile = await findUserProfile(payload, user.id as number); + } + if (!grantorProfile) { + return { success: false, error: "You do not have a profile." }; + } + + const progression = recipientProfile.progression; + const currentAwards = progression?.awards ?? []; + + await payload.update({ + collection: "profiles", + id: recipientProfile.id, + data: { + progression: { + ...progression, + awards: [ + ...currentAwards, + { + award: award.id, + awardedBy: grantorProfile.id, + awardedAt: new Date().toISOString(), + awardReason: input.reason ?? null, + }, + ], + }, + }, + overrideAccess: true, + }); + + await emitGameEvent(payload, { + type: EventTypes.AwardGrant, + message: `Granted "${award.name}" to recipient profile #${recipientProfile.id}`, + actor: user.id, + data: { awardId: award.id, grantorProfileId: grantorProfile.id }, + }); + + return { success: true }; + } catch (e) { + return { + success: false, + error: e instanceof Error ? e.message : "Unknown error", + }; + } +} diff --git a/src/app/(frontend)/awards/grant-context.ts b/src/app/(frontend)/awards/grant-context.ts new file mode 100644 index 0000000..05cfeb6 --- /dev/null +++ b/src/app/(frontend)/awards/grant-context.ts @@ -0,0 +1,105 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { isSuperuser } from "@/utils/access-control/hasPermission"; +import { countGrantsByGrantor, findUserProfile, getGrantorAssignments } from "@/lib/awards/grant"; + +/** + * Read-only grant context for a single award: whether the current user may + * grant it and how many grants (if limited) they have left. Mirrors the + * authorization logic in `grantAward` so the UI can warn before submitting. + */ +export interface GrantStats { + canGrant: boolean; + isSuperuser: boolean; + currentGrants: number; + maxGrants: number | null; + remainingGrants: number | null; +} + +interface ActionResult { + success: boolean; + error?: string; + data?: T; +} + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ + headers: hdrs, + canSetHeaders: false, + }); + + if (!isPayloadUser(user)) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +export async function getGrantStats(awardId: number): Promise> { + try { + const { payload, user } = await authenticate(); + + let award; + try { + award = await payload.findByID({ + collection: "awards", + id: awardId, + depth: 0, + overrideAccess: true, + }); + } catch { + return { success: false, error: "Award not found." }; + } + + const superuser = await isSuperuser(payload, user); + + let canGrant = false; + if (superuser) { + canGrant = true; + } else if (award.type === "medal") { + canGrant = false; + } else { + const allowedTypes = award.grantConfig?.allowedGrantorAssignmentTypes ?? []; + if (allowedTypes.length > 0) { + const grantorAssignments = await getGrantorAssignments( + payload, + user.id as number, + allowedTypes, + ); + canGrant = grantorAssignments.length > 0; + } + } + + let currentGrants = 0; + const maxGrants = superuser ? null : (award.grantConfig?.maxGrantsPerGrantor ?? null); + if (canGrant && maxGrants) { + const grantorProfile = await findUserProfile(payload, user.id as number); + if (grantorProfile) { + currentGrants = await countGrantsByGrantor(payload, awardId, grantorProfile.id); + } + } + + return { + success: true, + data: { + canGrant, + isSuperuser: superuser, + currentGrants, + maxGrants, + remainingGrants: maxGrants === null ? null : Math.max(maxGrants - currentGrants, 0), + }, + }; + } catch (e) { + return { + success: false, + error: e instanceof Error ? e.message : "Unknown error", + }; + } +} diff --git a/src/app/(frontend)/awards/page.tsx b/src/app/(frontend)/awards/page.tsx new file mode 100644 index 0000000..c45492c --- /dev/null +++ b/src/app/(frontend)/awards/page.tsx @@ -0,0 +1,86 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { Award, Media } from "@/payload-types"; +import { AwardWithGrantButton } from "@/components/frontend/account/AwardWithGrantButton"; +import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; +import { MedalIcon } from "lucide-react"; + +export const metadata = { + title: "Awards", + description: + "Browse every medal and ribbon that can be granted to a member of Polaris Task Force.", +}; + +const TYPE_LABEL: Record<"ribbon" | "medal", string> = { + ribbon: "Ribbons", + medal: "Medals", +}; + +export default async function AwardsPage() { + const payload = await getPayload({ config }); + + const res = await payload.find({ + collection: "awards", + where: { + type: { in: ["ribbon", "medal"] }, + }, + sort: "name", + limit: 100, + depth: 1, + overrideAccess: true, + }); + + const awards: Award[] = res.docs; + const ribbons = awards.filter((a) => a.type === "ribbon"); + const medals = awards.filter((a) => a.type === "medal"); + + const sections: { title: string; items: Award[] }[] = []; + if (medals.length > 0) sections.push({ title: TYPE_LABEL.medal, items: medals }); + if (ribbons.length > 0) sections.push({ title: TYPE_LABEL.ribbon, items: ribbons }); + + return ( +
+
+
+ +

Awards

+
+

+ A reference of every medal and ribbon that can be earned by a member of Polaris Task + Force. Review the requirements for each award and its artwork. Commendations are granted + on the fly and are not catalogued here. +

+
+ + {awards.length === 0 ? ( + + + No awards defined + + +

+ No medals or ribbons have been catalogued yet. Check back soon. +

+
+
+ ) : ( + sections.map((section) => ( +
+

+ {section.title} +

+
+ {section.items.map((award) => { + const awardImage = + typeof award.image === "object" && award.image ? (award.image as Media) : null; + return ( + + ); + })} +
+
+ )) + )} +
+ ); +} diff --git a/src/app/(frontend)/profile/[username]/page.tsx b/src/app/(frontend)/profile/[username]/page.tsx index 5d57d25..a22bff2 100644 --- a/src/app/(frontend)/profile/[username]/page.tsx +++ b/src/app/(frontend)/profile/[username]/page.tsx @@ -3,8 +3,9 @@ import { getPayload } from "payload"; import { notFound } from "next/navigation"; import { headers as nextHeaders } from "next/headers"; import Link from "next/link"; -import type { Assignment, Media, Qualification, Rank, User } from "@/payload-types"; +import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types"; import { RichText } from "@payloadcms/richtext-lexical/react"; +import { ClientDate } from "@/components/frontend/ClientDate"; import { resolveLevel } from "@/utils/xp/resolveLevel"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isSuperuser } from "@/utils/access-control/hasPermission"; @@ -39,6 +40,7 @@ import { ItemTitle, } from "@/components/ui/item"; import AwardCard from "@/components/frontend/account/AwardCard"; +import { ProfileGrantAwardButton } from "@/components/frontend/account/ProfileGrantAwardButton"; import { RibbonRack } from "@/components/frontend/account/RibbonRack"; import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection"; import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels"; @@ -282,6 +284,16 @@ export default async function ProfilePage({ params }: ProfilePageProps) { }); const assignments = assignmentRes.docs as Pick[]; + const grantableAwardsRes = await payload.find({ + collection: "awards", + where: { type: { in: ["ribbon", "commendation"] } }, + sort: "name", + limit: 100, + depth: 1, + overrideAccess: true, + }); + const grantableAwards = grantableAwardsRes.docs as Award[]; + const rankInsignia = typeof rank === "object" && rank.insignia && typeof rank.insignia === "object" ? (rank.insignia as Media) @@ -296,11 +308,6 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—"; const enlistDate = new Date(profile.dossier.enlistmentDate); - const enlistDateStr = enlistDate.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); const now = new Date(); const serviceMonths = (now.getFullYear() - enlistDate.getFullYear()) * 12 + (now.getMonth() - enlistDate.getMonth()); @@ -369,7 +376,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) { Enlisted - {enlistDateStr} + + + @@ -499,6 +511,10 @@ export default async function ProfilePage({ params }: ProfilePageProps) { )} + {grantableAwards.length > 0 && ( + + )} + {/* ── Combat Record ──────────────────────────────────── */}
diff --git a/src/components/frontend/account/AwardListingCard.tsx b/src/components/frontend/account/AwardListingCard.tsx new file mode 100644 index 0000000..0d4ead9 --- /dev/null +++ b/src/components/frontend/account/AwardListingCard.tsx @@ -0,0 +1,132 @@ +import { Award, Media } from "@/payload-types"; +import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item"; +import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; +import { RibbonViewer } from "@/components/frontend/account/RibbonViewer"; +import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { AwardIcon, LockIcon } from "lucide-react"; + +/** + * Card shown on the awards listing page. Unlike the profile's AwardCard + * (which wraps an individual's grant), this reviews a single definable award + * type — ribbon or medal — so users can inspect the award art and its + * full requirements. Commendations are intentionally out of scope. + * + * Ribbon art is rendered at a fixed, centered width so every ribbon (and its + * container) is an identical, justified size across the grid. Medal art is + * currently hidden; only the ribbon art is shown. + */ +interface AwardListingCardProps { + award: Award; + awardImage?: Media | null; + showGrantButton?: boolean; + onGrantClick?: () => void; + canGrant?: boolean | null; + remainingGrants?: number | null; +} + +export function AwardListingCard({ + award, + awardImage, + showGrantButton = false, + onGrantClick, + canGrant, + remainingGrants, +}: AwardListingCardProps) { + const type = award.type ?? "commendation"; + const isMedal = type === "medal"; + const hasRibbonArt = + type === "ribbon" && + award.ribbonDesign && + typeof award.ribbonDesign === "object" && + Array.isArray((award.ribbonDesign as RibbonDesign).stripes); + const hasArt = hasRibbonArt || Boolean(awardImage?.url); + + return ( + + + {hasArt && ( +
+ {hasRibbonArt ? ( +
+ +
+ ) : awardImage?.url ? ( + {award.name} + ) : null} +
+ )} + + + {award.name} + + + {type} + +
+ + + + {award.description + ? convertLexicalToPlaintext({ data: award.description }) + : "No description available."} + + {award.experiencePoints != null && ( + + +{award.experiencePoints} XP + + )} + + + {showGrantButton && ( +
+ {canGrant === false ? ( + + ) : onGrantClick ? ( + + ) : null} +
+ )} +
+ ); +} diff --git a/src/components/frontend/account/AwardPickerDialog.tsx b/src/components/frontend/account/AwardPickerDialog.tsx new file mode 100644 index 0000000..b6e2eb0 --- /dev/null +++ b/src/components/frontend/account/AwardPickerDialog.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import type { Award } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { RibbonDesign } from "@/components/admin/awards/RibbonDesigner"; +import { RibbonViewer } from "./RibbonViewer"; +import { GrantAwardDialog } from "./GrantAwardDialog"; +import { MedalIcon, RibbonIcon, ScrollTextIcon } from "lucide-react"; + +interface AwardPickerDialogProps { + awards: Award[]; + recipientUserId: number; + recipientUsername: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onGranted?: () => void; +} + +/** + * Grid of grantable awards. Selecting a card closes this dialog and opens the + * GrantAwardDialog for that award with the recipient pre-filled. + */ +export function AwardPickerDialog({ + awards, + recipientUserId, + recipientUsername, + open, + onOpenChange, + onGranted, +}: AwardPickerDialogProps) { + const [selectedAward, setSelectedAward] = useState(null); + + function pick(award: Award) { + setSelectedAward(award); + onOpenChange(false); + } + + return ( + <> + { + if (!o) setSelectedAward(null); + onOpenChange(o); + }} + > + + + Select an award + + Choose which award to grant to @{recipientUsername}. + + + {awards.length === 0 ? ( +

+ No awards are available to grant. +

+ ) : ( +
+ {awards.map((award) => ( + + ))} +
+ )} +
+
+ + {selectedAward && ( + { + if (!o) setSelectedAward(null); + }} + onGranted={onGranted} + /> + )} + + ); +} diff --git a/src/components/frontend/account/AwardWithGrantButton.tsx b/src/components/frontend/account/AwardWithGrantButton.tsx new file mode 100644 index 0000000..9aa30e5 --- /dev/null +++ b/src/components/frontend/account/AwardWithGrantButton.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Award, Media } from "@/payload-types"; +import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; +import { AwardListingCard } from "./AwardListingCard"; +import { GrantAwardDialog } from "./GrantAwardDialog"; +import { getGrantStats } from "@/app/(frontend)/awards/grant-context"; + +interface AwardWithGrantButtonProps { + award: Award; + awardImage?: Media | null; +} + +export function AwardWithGrantButton({ award, awardImage }: AwardWithGrantButtonProps) { + const [open, setOpen] = useState(false); + const [canGrant, setCanGrant] = useState(null); + const [remainingGrants, setRemainingGrants] = useState(undefined); + const router = useRouter(); + + useEffect(() => { + let cancelled = false; + getGrantStats(award.id).then((res) => { + if (cancelled) return; + if (res.success && res.data) { + setCanGrant(res.data.canGrant); + setRemainingGrants(res.data.remainingGrants); + } else { + setCanGrant(null); + } + }); + return () => { + cancelled = true; + }; + }, [award.id]); + + return ( + <> + setOpen(true)} + canGrant={canGrant} + remainingGrants={remainingGrants} + /> + router.refresh()} + /> + + ); +} diff --git a/src/components/frontend/account/GrantAwardDialog.tsx b/src/components/frontend/account/GrantAwardDialog.tsx new file mode 100644 index 0000000..4279923 --- /dev/null +++ b/src/components/frontend/account/GrantAwardDialog.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { grantAward } from "@/app/(frontend)/awards/actions"; +import { getGrantStats, type GrantStats } from "@/app/(frontend)/awards/grant-context"; +import { RecipientPicker, type RecipientSelection } from "./RecipientPicker"; +import { Loader2Icon, TriangleAlertIcon } from "lucide-react"; +import { toast } from "sonner"; + +interface GrantAwardDialogProps { + awardId: number; + awardName: string; + awardDescription?: string; + recipientUserId?: number; + recipientUsername?: string; + showRecipientPicker?: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onGranted?: () => void; +} + +/** + * Two-step grant flow: pick a recipient + reason, then confirm by typing the + * recipient's @username. Grant limits are pre-checked via getGrantStats so + * users see warnings before submitting. + */ +export function GrantAwardDialog({ + awardId, + awardName, + awardDescription, + recipientUserId, + recipientUsername, + showRecipientPicker = false, + open, + onOpenChange, + onGranted, +}: GrantAwardDialogProps) { + const [step, setStep] = useState<1 | 2>(1); + const [recipient, setRecipient] = useState(null); + const [reason, setReason] = useState(""); + const [confirmText, setConfirmText] = useState(""); + const [stats, setStats] = useState(null); + const [statsError, setStatsError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Reset the flow each time the dialog opens; pre-fill a fixed recipient. + useEffect(() => { + if (!open) return; + setStep(1); + setReason(""); + setConfirmText(""); + setStats(null); + setStatsError(null); + setError(null); + setSubmitting(false); + setRecipient( + !showRecipientPicker && recipientUserId != null && recipientUsername + ? { id: recipientUserId, username: recipientUsername } + : null, + ); + }, [open, showRecipientPicker, recipientUserId, recipientUsername]); + + // Load grant context for the award while the dialog is open. + useEffect(() => { + if (!open) return; + let cancelled = false; + getGrantStats(awardId).then((res) => { + if (cancelled) return; + if (res.success && res.data) setStats(res.data); + else setStatsError(res.error ?? "Failed to load grant stats."); + }); + return () => { + cancelled = true; + }; + }, [open, awardId]); + + const expectedConfirm = recipient ? `@${recipient.username}` : null; + const confirmMatches = expectedConfirm !== null && confirmText.trim() === expectedConfirm; + const limited = stats?.remainingGrants != null; + const lowRemaining = limited && (stats?.remainingGrants ?? 0) <= 2; + + async function handleGrant() { + if (!recipient || !confirmMatches || submitting) return; + setSubmitting(true); + setError(null); + const res = await grantAward({ + recipientUserId: recipient.id, + awardId, + reason: reason.trim() ? reason.trim() : undefined, + }); + if (res.success) { + toast.success("Award granted", { + description: `${awardName} granted to @${recipient.username}.`, + }); + onGranted?.(); + onOpenChange(false); + } else { + setError(res.error ?? "Failed to grant award."); + setSubmitting(false); + } + } + + return ( + { + if (!submitting) onOpenChange(o); + }} + > + + + Grant {awardName} + + {showRecipientPicker + ? "Select a recipient, then confirm the grant." + : `Granting to @${recipientUsername}.`} + {stats && stats.canGrant && ( + + {stats.remainingGrants === null + ? "Unlimited grants available." + : `${stats.remainingGrants} grant${stats.remainingGrants === 1 ? "" : "s"} remaining.`} + + )} + + + + {step === 1 ? ( +
+ {awardDescription && ( +

{awardDescription}

+ )} + + {showRecipientPicker ? ( +
+ + setRecipient(null)} + /> +
+ ) : ( +

+ Recipient: @{recipientUsername} +

+ )} + +
+ +