| 1 | "use client"; |
| 2 | |
| 3 | import { useMutation, useQuery |
| 4 | import Link from "next/link |
| 5 | import { toast } |
| 6 | |
| 7 | import { FromNowHoverCard } |
| 8 | import { Button } |
| 9 | import { Card, CardContent |
| 10 | import { apiClient, apiErrorMessage |
| 11 | import { authClient } |
| 12 | import { organizationRoleOf } |
| 13 | |
| 14 | type Kind |
| 15 | |
| 16 | /** One pending invitation: to a repository, to an organization, or to take a repository over. */ |
| 17 | type Pending |
| 18 | kind: Kind |
| 19 | id: string |
| 20 | /** `owner/repo` or the organization's slug, which is also where it lives. */ |
| 21 | name: string |
| 22 | /** The role offered, or for a transfer where the repository would go. */ |
| 23 | role: string |
| 24 | invitedBy: string |
| 25 | expiresAt: string |
| 26 | }; |
| 27 | |
| 28 | /** Pending invitations to collaborate on a repository or to join an organization. Renders nothing when there are none. */ |
| 29 | export function Invitations() |
| 30 | const queryClient = |
| 31 | |
| 32 | const { data: |
| 33 | queryKey: ["invitations", |
| 34 | queryFn: async () |
| 35 | const { data, |
| 36 | if (error) throw |
| 37 | return data.invitations |
| 38 | kind: "repository", |
| 39 | id: invitation.id |
| 40 | name: `${invitation. |
| 41 | role: invitation.role |
| 42 | invitedBy: invitation.invitedByUsername |
| 43 | expiresAt: invitation.expiresAt |
| 44 | })); |
| 45 | }, |
| 46 | }); |
| 47 | const { data: |
| 48 | queryKey: ["invitations", |
| 49 | queryFn: async () |
| 50 | const { data, |
| 51 | "/api/invitations/organizations", |
| 52 | ); |
| 53 | if (error) throw |
| 54 | return data.invitations |
| 55 | kind: "organization", |
| 56 | id: invitation.id |
| 57 | name: invitation.organization |
| 58 | role: organizationRoleOf(invitation |
| 59 | invitedBy: invitation.invitedByUsername |
| 60 | expiresAt: invitation.expiresAt |
| 61 | })); |
| 62 | }, |
| 63 | }); |
| 64 | |
| 65 | const { data: |
| 66 | queryKey: ["invitations", |
| 67 | queryFn: async () |
| 68 | const { data, |
| 69 | if (error) throw |
| 70 | return data.transfers |
| 71 | kind: "transfer", |
| 72 | id: transfer.repositoryId |
| 73 | name: `${transfer. |
| 74 | role: transfer.to |
| 75 | invitedBy: transfer.requestedByUsername |
| 76 | expiresAt: null, |
| 77 | })); |
| 78 | }, |
| 79 | }); |
| 80 | |
| 81 | const respond = |
| 82 | mutationFn: async ( |
| 83 | kind, |
| 84 | id, |
| 85 | accept, |
| 86 | }: { |
| 87 | kind: Kind |
| 88 | id: string |
| 89 | accept: boolean |
| 90 | }) => { |
| 91 | const { error |
| 92 | kind === "transfer" |
| 93 | ? accept |
| 94 | ? await apiClient. |
| 95 | params: { path: { |
| 96 | }) |
| 97 | : await apiClient. |
| 98 | params: { path: { |
| 99 | }) |
| 100 | : kind === " |
| 101 | ? accept |
| 102 | ? await authClient. |
| 103 | invitationId: id, |
| 104 | }) |
| 105 | : await authClient. |
| 106 | invitationId: id, |
| 107 | }) |
| 108 | : accept |
| 109 | ? await apiClient. |
| 110 | params: { path: { |
| 111 | }) |
| 112 | : await apiClient. |
| 113 | params: { path: { |
| 114 | }); |
| 115 | if (error) throw |
| 116 | }, |
| 117 | onSuccess: (_, |
| 118 | Promise.all([ |
| 119 | queryClient.invalidateQueries({ |
| 120 | // Accepting puts repositories and organizations in the viewer's lists. |
| 121 | accept && |
| 122 | queryClient.invalidateQueries({ |
| 123 | accept && |
| 124 | queryClient.invalidateQueries({ |
| 125 | ]), |
| 126 | onError: (error: |
| 127 | }); |
| 128 | |
| 129 | const pending = |
| 130 | if (pending.length |
| 131 | |
| 132 | return ( |
| 133 | <Card> |
| 134 | <CardHeader> |
| 135 | <CardTitle className=" |
| 136 | </CardHeader> |
| 137 | <CardContent> |
| 138 | <ul className="flex flex-col divide-y |
| 139 | {pending.map(( |
| 140 | <li |
| 141 | key={`${invitation |
| 142 | className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center" |
| 143 | > |
| 144 | <p className="flex-1 text-sm |
| 145 | {invitation.invitedBy ?? |
| 146 | {invitation.kind === |
| 147 | ? "wants to transfer " |
| 148 | : invitation.kind |
| 149 | ? "invited you to join " |
| 150 | : "invited you to "} |
| 151 | <Link |
| 152 | href={`/${invitation |
| 153 | className="font-medium text-primary hover:underline" |
| 154 | > |
| 155 | {invitation.name} |
| 156 | </Link>{" " |
| 157 | {invitation.kind === |
| 158 | <> |
| 159 | to <span className="font-medium |
| 160 | </> |
| 161 | ) : ( |
| 162 | <> |
| 163 | {invitation.kind === |
| 164 | <span className="font-medium |
| 165 | {invitation.kind === |
| 166 | </> |
| 167 | )}{" "} |
| 168 | {invitation.expiresAt && |
| 169 | <span className="text-muted-foreground |
| 170 | Expires <FromNowHoverCard date={ |
| 171 | </span> |
| 172 | )} |
| 173 | </p> |
| 174 | <div className="flex gap-2 |
| 175 | <Button |
| 176 | size="sm" |
| 177 | disabled={respond.isPending |
| 178 | onClick={() => |
| 179 | respond.mutate({ |
| 180 | } |
| 181 | > |
| 182 | Accept |
| 183 | </Button> |
| 184 | <Button |
| 185 | size="sm" |
| 186 | variant="outline" |
| 187 | disabled={respond.isPending |
| 188 | onClick={() => |
| 189 | respond.mutate({ |
| 190 | } |
| 191 | > |
| 192 | Decline |
| 193 | </Button> |
| 194 | </div> |
| 195 | </li> |
| 196 | ))} |
| 197 | </ul> |
| 198 | </CardContent> |
| 199 | </Card> |
| 200 | ); |
| 201 | } |