diff --git a/src/app/hooks/useRegistrationInvites.ts b/src/app/hooks/useRegistrationInvites.ts new file mode 100644 index 00000000..b2b0a528 --- /dev/null +++ b/src/app/hooks/useRegistrationInvites.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { AsyncStatus, useAsyncCallback } from './useAsyncCallback'; +import { + RegistrationBotConfig, + RegistrationInvite, + RegistrationInvitesResult, + requestRegistrationInvites, +} from '../plugins/registration-bot'; + +/** + * The registration links the bot has generated for the current user, loaded on mount. + * `refresh` reloads them (e.g. after minting a new one) while keeping the last list + * on screen until the new one arrives. + */ +export const useRegistrationInvites = (botConfig: RegistrationBotConfig) => { + const mx = useMatrixClient(); + const [invites, setInvites] = useState(); + + const [state, refresh] = useAsyncCallback( + useCallback(() => requestRegistrationInvites(mx, botConfig), [mx, botConfig]) + ); + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + if (state.status === AsyncStatus.Success && state.data.success) { + setInvites(state.data.invites); + } + }, [state]); + + const loading = state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading; + + let error: string | undefined; + if (state.status === AsyncStatus.Error) error = state.error.message; + else if (state.status === AsyncStatus.Success && !state.data.success) { + error = state.data.error.message; + } + + return { invites, loading, error, refresh }; +}; diff --git a/src/app/pages/app-shell/InviteFlow.css.ts b/src/app/pages/app-shell/InviteFlow.css.ts index b8741f81..61745d3c 100644 --- a/src/app/pages/app-shell/InviteFlow.css.ts +++ b/src/app/pages/app-shell/InviteFlow.css.ts @@ -150,6 +150,9 @@ export const Error = style({ export const Actions = style({ marginTop: 'auto', + // Stays in view while a long invite list scrolls underneath. + position: 'sticky', + bottom: 0, display: 'flex', flexDirection: 'column', flexShrink: 0, @@ -191,3 +194,81 @@ export const SubmitButton = style({ }, }, }); + +export const ListHeader = style({ + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + gap: '12px', +}); + +export const ListSummary = style({ + fontSize: '13px', + fontWeight: 600, + color: '#7c8d97', +}); + +export const ListEmpty = style({ + fontSize: '14px', + fontWeight: 500, + color: '#7c8d97', +}); + +export const InviteList = style({ + listStyle: 'none', + margin: 0, + padding: 0, + display: 'flex', + flexDirection: 'column', + gap: '10px', +}); + +export const InviteCard = style({ + background: '#fff', + borderRadius: '16px', + padding: '12px 16px', + boxShadow: '0 1px 2px rgba(30, 42, 50, 0.05), 0 10px 24px rgba(30, 42, 50, 0.05)', + display: 'flex', + flexDirection: 'column', + gap: '4px', + minWidth: 0, +}); + +export const InviteRow = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '10px', + minWidth: 0, +}); + +export const InviteDetail = style({ + fontSize: '14px', + fontWeight: 500, + color: '#2b3a43', + overflowWrap: 'anywhere', +}); + +export const Badge = style({ + flexShrink: 0, + borderRadius: '999px', + padding: '3px 10px', + fontSize: '12px', + fontWeight: 700, + whiteSpace: 'nowrap', +}); + +export const BadgePositive = style({ + background: '#e3f4ea', + color: '#1e7a45', +}); + +export const BadgeNeutral = style({ + background: '#e2f1f9', + color: '#1273a0', +}); + +export const BadgeMuted = style({ + background: '#edf0f2', + color: '#6b7a83', +}); diff --git a/src/app/pages/app-shell/InviteFlow.tsx b/src/app/pages/app-shell/InviteFlow.tsx index c0f04855..cd652a96 100644 --- a/src/app/pages/app-shell/InviteFlow.tsx +++ b/src/app/pages/app-shell/InviteFlow.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import FocusTrap from 'focus-trap-react'; import { QRCodeSVG } from 'qrcode.react'; import { RegistrationBotConfig } from '../../plugins/registration-bot'; import { useRegistrationLink } from '../../hooks/useRegistrationLink'; +import { useRegistrationInvites } from '../../hooks/useRegistrationInvites'; +import { describeInvite, summarizeInvites } from './inviteList'; import { stopPropagation } from '../../utils/keyboard'; import * as css from './InviteFlow.css'; @@ -36,6 +38,64 @@ const LINK_ICON = ( ); +const formatTime = (ms: number): string => + new Date(ms).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + +const TONE_CLASS = { + positive: css.BadgePositive, + neutral: css.BadgeNeutral, + muted: css.BadgeMuted, +}; + +function InviteList({ botConfig, link }: { botConfig: RegistrationBotConfig; link?: string }) { + const { invites, loading, error, refresh } = useRegistrationInvites(botConfig); + + // A newly generated link belongs in the list, so reload it once one arrives. + useEffect(() => { + if (link) refresh(); + }, [link, refresh]); + + return ( +
+
+ + Your Invites + + {invites && invites.length > 0 && ( + {summarizeInvites(invites)} + )} +
+ {error && {error}} + {!invites && loading && Loading invites…} + {invites && invites.length === 0 && ( + You haven't generated any invite links yet. + )} + {invites && invites.length > 0 && ( +
    + {invites.map((invite) => { + const { label, tone, detail } = describeInvite(invite, formatTime); + return ( +
  • +
    + + {invite.issuedAt === null ? 'Unknown date' : formatTime(invite.issuedAt)} + + {label} +
    + {detail &&
    {detail}
    } +
    + {invite.clientId && `${invite.clientId} · `} + {`Link ${invite.tokenSha256.slice(0, 8)}…`} +
    +
  • + ); + })} +
+ )} +
+ ); +} + type InviteFlowProps = { open: boolean; botConfig: RegistrationBotConfig; @@ -85,7 +145,7 @@ function InviteForm({ botConfig, onClose }: Omit) {

Get a single-use link you can send to someone to invite them to create an account on - this server. + this server, and see who has used the links you already sent.

{clients && clients.length > 1 && !link && (
@@ -129,6 +189,7 @@ function InviteForm({ botConfig, onClose }: Omit) {
)} +
{link ? (