Skip to content
Merged
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
42 changes: 42 additions & 0 deletions src/app/hooks/useRegistrationInvites.ts
Original file line number Diff line number Diff line change
@@ -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<RegistrationInvite[]>();

const [state, refresh] = useAsyncCallback<RegistrationInvitesResult, Error, []>(
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 };
};
81 changes: 81 additions & 0 deletions src/app/pages/app-shell/InviteFlow.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
});
65 changes: 63 additions & 2 deletions src/app/pages/app-shell/InviteFlow.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -36,6 +38,64 @@ const LINK_ICON = (
</svg>
);

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 (
<section className={css.Field} aria-labelledby="invite-list-title">
<div className={css.ListHeader}>
<span className={css.Label} id="invite-list-title">
Your Invites
</span>
{invites && invites.length > 0 && (
<span className={css.ListSummary}>{summarizeInvites(invites)}</span>
)}
</div>
{error && <span className={css.Error}>{error}</span>}
{!invites && loading && <span className={css.ListEmpty}>Loading invites…</span>}
{invites && invites.length === 0 && (
<span className={css.ListEmpty}>You haven&apos;t generated any invite links yet.</span>
)}
{invites && invites.length > 0 && (
<ul className={css.InviteList}>
{invites.map((invite) => {
const { label, tone, detail } = describeInvite(invite, formatTime);
return (
<li key={invite.tokenSha256} className={css.InviteCard}>
<div className={css.InviteRow}>
<span className={css.OptionTitle}>
{invite.issuedAt === null ? 'Unknown date' : formatTime(invite.issuedAt)}
</span>
<span className={`${css.Badge} ${TONE_CLASS[tone]}`}>{label}</span>
</div>
{detail && <div className={css.InviteDetail}>{detail}</div>}
<div className={css.OptionSubtitle}>
{invite.clientId && `${invite.clientId} · `}
{`Link ${invite.tokenSha256.slice(0, 8)}…`}
</div>
</li>
);
})}
</ul>
)}
</section>
);
}

type InviteFlowProps = {
open: boolean;
botConfig: RegistrationBotConfig;
Expand Down Expand Up @@ -85,7 +145,7 @@ function InviteForm({ botConfig, onClose }: Omit<InviteFlowProps, 'open'>) {
<div className={css.Body}>
<p className={css.Description}>
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.
</p>
{clients && clients.length > 1 && !link && (
<div className={css.Field}>
Expand Down Expand Up @@ -129,6 +189,7 @@ function InviteForm({ botConfig, onClose }: Omit<InviteFlowProps, 'open'>) {
</button>
</div>
)}
<InviteList botConfig={botConfig} link={link} />
<div className={css.Actions}>
{link ? (
<button type="button" className={css.SubmitButton} onClick={onClose}>
Expand Down
68 changes: 68 additions & 0 deletions src/app/pages/app-shell/inviteList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import type { RegistrationInvite } from '../../plugins/registration-bot';
import { describeInvite, summarizeInvites } from './inviteList';

const invite = (overrides: Partial<RegistrationInvite> = {}): RegistrationInvite => ({
tokenSha256: 'abc',
status: 'unused',
clientId: null,
issuedAt: 1,
expiresAt: null,
registeredUserId: null,
registeredAt: null,
...overrides,
});

const formatTime = (ms: number) => `t${ms}`;

describe('describeInvite', () => {
it('names who used a link and when', () => {
expect(
describeInvite(
invite({ status: 'used', registeredUserId: '@carol:example.org', registeredAt: 5 }),
formatTime
)
).toEqual({ label: 'Used', tone: 'positive', detail: 'Used by @carol:example.org on t5' });
});

it('leaves out the date when the registration time is unknown', () => {
expect(
describeInvite(invite({ status: 'used', registeredUserId: '@carol:example.org' }), formatTime)
.detail
).toBe('Used by @carol:example.org');
});

it('has no detail for a used link with no known user', () => {
expect(describeInvite(invite({ status: 'used' }), formatTime)).toEqual({
label: 'Used',
tone: 'positive',
});
});

it('shows when an unused link expires', () => {
expect(describeInvite(invite({ expiresAt: 9 }), formatTime).detail).toBe('Expires t9');
expect(describeInvite(invite(), formatTime).detail).toBe('Never expires');
});

it('mutes expired and revoked links', () => {
expect(describeInvite(invite({ status: 'expired' }), formatTime)).toEqual({
label: 'Expired',
tone: 'muted',
});
expect(describeInvite(invite({ status: 'revoked' }), formatTime)).toEqual({
label: 'Revoked',
tone: 'muted',
});
});

it('describes a pending link', () => {
expect(describeInvite(invite({ status: 'pending' }), formatTime).label).toBe('In progress');
});
});

describe('summarizeInvites', () => {
it('counts invites and used ones', () => {
expect(summarizeInvites([invite(), invite({ status: 'used' })])).toBe('2 invites · 1 used');
expect(summarizeInvites([invite()])).toBe('1 invite · 0 used');
});
});
59 changes: 59 additions & 0 deletions src/app/pages/app-shell/inviteList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { RegistrationInvite, RegistrationInviteStatus } from '../../plugins/registration-bot';

export type InviteTone = 'positive' | 'neutral' | 'muted';

export type InviteDescription = {
/** Short badge text, e.g. "Used". */
label: string;
tone: InviteTone;
/** A line of detail under the invite, e.g. "Used by @carol:example.org on …". */
detail?: string;
};

const LABELS: Record<RegistrationInviteStatus, string> = {
unused: 'Not used yet',
pending: 'In progress',
used: 'Used',
expired: 'Expired',
revoked: 'Revoked',
};

const TONES: Record<RegistrationInviteStatus, InviteTone> = {
unused: 'neutral',
pending: 'neutral',
used: 'positive',
expired: 'muted',
revoked: 'muted',
};

/** How to show an invite's status. `formatTime` turns a millisecond timestamp into text. */
export const describeInvite = (
invite: RegistrationInvite,
formatTime: (ms: number) => string
): InviteDescription => {
const label = LABELS[invite.status];
const tone = TONES[invite.status];

switch (invite.status) {
case 'used': {
if (invite.registeredUserId === null) return { label, tone };
const when = invite.registeredAt === null ? '' : ` on ${formatTime(invite.registeredAt)}`;
return { label, tone, detail: `Used by ${invite.registeredUserId}${when}` };
}
case 'pending':
return { label, tone, detail: 'Someone is registering with this link now' };
case 'unused':
return invite.expiresAt === null
? { label, tone, detail: 'Never expires' }
: { label, tone, detail: `Expires ${formatTime(invite.expiresAt)}` };
default:
return { label, tone };
}
};

/** "3 invites · 1 used" — a one-line summary above the list. */
export const summarizeInvites = (invites: RegistrationInvite[]): string => {
const used = invites.filter((invite) => invite.status === 'used').length;
const total = `${invites.length} ${invites.length === 1 ? 'invite' : 'invites'}`;
return `${total} · ${used} used`;
};
Loading
Loading