feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161

Merged
whitlocktech merged 45 commits from edge into main 2026-08-19 08:57:13 +00:00
8 changed files with 563 additions and 0 deletions
Showing only changes of commit b458c1f46f - Show all commits

View File

@@ -57,6 +57,8 @@ import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
export default function App() {
@@ -204,6 +206,10 @@ export default function App() {
<Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
{/* PUBLIC, and grouped with the other tokened landings above rather
than with the portal below: the person following an unsubscribe
link is reading their mail, not signed in (TEAMS.md §6.4). */}
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
<Route
element={
<RequirePlayer>
@@ -218,6 +224,7 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
<Route path="/account/notifications" element={<PlayerNotifications />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one

View File

@@ -187,6 +187,26 @@ export const api = {
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
teamGrantRevoke: (slug, userId) =>
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
// ----- notifications (TEAMS.md Part 6) -----
//
// Under /auth/me rather than /player: these are role-agnostic self-service, the
// same rule that put the forum under /player rather than behind a staff gate.
// The streams catalog and the per-stream subscriptions were built for the app
// and had no web consumer at all until phase 6 gave them one.
notificationStreams: () => req('/auth/me/notifications/streams'),
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
// `streams` is always sent, empty array included — the endpoint requires the
// field, so clearing the last subscription must not become an absent key.
setNotificationSubscriptions: (streams) =>
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
setTeamNotificationPrefs: (teams) =>
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
// Unauthenticated, and the one write in the public tier: the caller is reading
// their mail, not signed in. Always resolves 200 whatever the token was.
unsubscribeTeam: (token) =>
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link

View File

@@ -6,6 +6,7 @@ import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import TeamForumPanel from './modules/TeamForumPanel.jsx'
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -84,6 +85,12 @@ fillModuleSlot('uo.guild.detail', TeamActivityFeed)
// exactly as before.
fillModuleSlot('uo.guild.forum', TeamForumPanel)
// And the notification control, in a third place the module declares ABOVE its
// roster. A third slot rather than a corner of the feed for the same reason there
// were two: this is an action on the page and the other two are content in it,
// and only the module can say where each belongs on a page it owns.
fillModuleSlot('uo.guild.header', TeamNotifyToggle)
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//

View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
// Core's per-Team notification control, rendered into a THIRD slot a module
// declares (TEAMS.md §6.3, phase 6).
//
// **Why this is a slot at all, and why it is the third one.** Teams have no core
// page — the module that owns the vocabulary owns the page — so a control that
// acts on one Team has nowhere of core's to live. The feed and the forum go below
// the module's roster; this goes above it, because muting a guild is an action ON
// the page rather than more content in it, and that is exactly the placement
// decision a module cannot make if core stacks everything into one fill.
//
// **It renders nothing for a viewer who is not in the Team**, including anonymous
// ones, and that is a privacy property rather than a tidiness one: whether a
// notification preference EXISTS for a Team answers "is this person in it", and
// the guild page is public. The server decides — the preference list only contains
// Teams the caller may be notified about — and this file never infers membership
// from anything it can see on the page.
//
// **Muting is per-Team and covers all four streams.** The per-stream on/off lives
// on the account screen, where the catalog does; the thing that could not be
// expressed before phase 6 is "I am in five Teams and want notifications from
// one", and that is the only question this control asks.
export default function TeamNotifyToggle({ externalId, moduleId }) {
const { user } = useAuth()
const [state, setState] = useState({ loading: true, team: null, pref: null })
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
// Anonymous viewers never fetch. The endpoint would 401 harmlessly, but a
// guild page rendering a public roster should not put an authenticated
// request on the wire for every visitor.
if (!user) return setState({ loading: false, team: null, pref: null })
try {
const team = await api.teamByExternalId(moduleId, externalId)
const { teams } = await api.teamNotificationPrefs()
const pref = (teams || []).find((t) => t.teamId === team.id) || null
setState({ loading: false, team, pref })
} catch {
// Same rule as the feed and the forum: this is core's content on a page
// core does not own, so a failure renders nothing rather than putting an
// error box on somebody else's surface.
setState({ loading: false, team: null, pref: null })
}
}, [externalId, moduleId, user])
useEffect(() => { load() }, [load])
const { loading, pref } = state
if (loading || !pref) return null
async function toggle() {
setBusy(true)
// Optimistic, and reconciled from the server's echo rather than assumed: a
// PUT that silently dropped the entry (a Team left in another tab) must not
// leave the control claiming a state the server does not hold.
const next = { ...pref, muted: !pref.muted }
setState((s) => ({ ...s, pref: next }))
try {
const { teams } = await api.setTeamNotificationPrefs([
{ teamId: pref.teamId, muted: next.muted, emailMode: pref.emailMode },
])
const echoed = (teams || []).find((t) => t.teamId === pref.teamId)
if (echoed) setState((s) => ({ ...s, pref: echoed }))
} catch {
setState((s) => ({ ...s, pref }))
} finally {
setBusy(false)
}
}
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
margin: '10px 0 0',
fontSize: '0.84rem',
}}
>
<button type="button" onClick={toggle} disabled={busy} className="btn btn-sq">
{pref.muted ? 'Unmute notifications' : 'Mute notifications'}
</button>
<span className="dim">
{pref.muted
? 'You get no notifications about this team.'
: 'You get notifications about this team.'}
</span>
{/* The one link off this control, because "mute" is a blunt answer to a
question the account screen asks properly — which streams, and whether
email is on at all. */}
<Link to="/account/notifications" className="dim">All notification settings</Link>
</div>
)
}

View File

@@ -0,0 +1,268 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
//
// **This screen did not exist before phase 6, and that was the phase's first
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
// notification settings screen" — there was no such screen on the web. The stream
// catalog and the per-stream subscriptions have been built and shipped since M7,
// with the Android app as their only consumer; a browser could not see them at
// all. That is tolerable for push, which needs the app anyway. It is not tolerable
// for email, whose whole reason for existing (§6.4) is the web-only user who runs
// neither the app nor Discord — so the sink and the screen to configure it had to
// arrive together.
//
// Three blocks, in the order a user actually reasons about them: what kinds of
// thing to be told about, then which Teams, then whether any of it should reach a
// mailbox.
const EMAIL_MODES = [
{ value: 'off', label: 'No email' },
{ value: 'digest', label: 'Daily digest' },
{ value: 'immediate', label: 'Every post' },
]
// Streams whose scoping lives in this page's second block rather than in the
// first. Shown as a group so a user does not toggle `team.forum.post` off site-
// wide when what they meant was "not this one guild".
const isTeamStream = (id) => String(id).startsWith('team.')
function Section({ title, hint, children }) {
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
{hint && <p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.86rem' }}>{hint}</p>}
{children}
</section>
)
}
function Note({ msg, error }) {
if (!msg && !error) return null
return (
<p className="sans" style={{ margin: '10px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>
{error || msg}
</p>
)
}
// ── What to be told about ──────────────────────────────────────────────────
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed))
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
const toggle = (id) => {
const next = new Set(set)
if (next.has(id)) next.delete(id)
else next.add(id)
setSet(next)
}
const team = streams.filter((s) => isTeamStream(s.id))
const rest = streams.filter((s) => !isTeamStream(s.id))
const row = (s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
<span>
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
</span>
</label>
)
return (
<Section
title="What to notify me about"
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
>
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
{team.length > 0 && (
<>
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
Teams
</h3>
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
</>
)}
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
<Note msg={msg} error={error} />
</Section>
)
}
// ── Which Teams, and whether by email ──────────────────────────────────────
function Teams({ teams, onSave, busy, msg, error }) {
const [rows, setRows] = useState(teams)
useEffect(() => { setRows(teams) }, [teams])
const patch = (teamId, change) =>
setRows((rs) => rs.map((r) => (r.teamId === teamId ? { ...r, ...change } : r)))
if (rows.length === 0) {
return (
<Section title="Teams">
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
You are not in a team, and nobody has given you access to a team forum. There is nothing to
configure here yet.
</p>
</Section>
)
}
return (
<Section
title="Teams"
hint="Muting a team silences all four team notifications for it, without changing anything for your other teams. Email is off until you turn it on."
>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Team</th>
<th style={{ padding: '8px 10px' }}>Notifications</th>
<th style={{ padding: '8px 10px' }}>Email</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.teamId} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
{t.name}
{/* An archived Team is still listed when a preference exists for
it, so a mute does not silently vanish when a guild disbands
and reappear if it re-forms under the same name. */}
{t.archived && <span className="dim" style={{ fontSize: '0.78rem' }}> · archived</span>}
</td>
<td style={{ padding: '10px' }}>
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: '0.88rem' }}>
<input type="checkbox" checked={!t.muted} onChange={() => patch(t.teamId, { muted: !t.muted })} />
<span className="dim">{t.muted ? 'Muted' : 'On'}</span>
</label>
</td>
<td style={{ padding: '10px' }}>
<select
className="input"
value={t.emailMode}
onChange={(e) => patch(t.teamId, { emailMode: e.target.value })}
style={{ fontSize: '0.88rem' }}
>
{EMAIL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave(rows)}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
<Note msg={msg} error={error} />
</Section>
)
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [streams, setStreams] = useState([])
const [subscribed, setSubscribed] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
const load = useCallback(async () => {
setLoading(true)
try {
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
// the Team list are this user's. None depends on another.
const [cat, subs, prefs] = await Promise.all([
api.notificationStreams(),
api.notificationSubscriptions(),
api.teamNotificationPrefs(),
])
setStreams(cat.streams || [])
setSubscribed(subs.streams || [])
setTeams(prefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => {
setSaving((s) => ({ ...s, streams: true }))
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
try {
const { streams: stored } = await api.setNotificationSubscriptions(ids)
setSubscribed(stored || [])
setNotes((n) => ({ ...n, streams: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, streams: false }))
}
}, [])
const saveTeams = useCallback(async (rows) => {
setSaving((s) => ({ ...s, teams: true }))
setNotes((n) => ({ ...n, teams: '', teamsError: '' }))
try {
// The whole set, every time, and the array is sent even when empty — the
// endpoint requires the field (docs/android/PLAN.md §11).
const { teams: stored } = await api.setTeamNotificationPrefs(
rows.map((t) => ({ teamId: t.teamId, muted: t.muted, emailMode: t.emailMode })),
)
setTeams(stored || [])
setNotes((n) => ({ ...n, teams: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, teamsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, teams: false }))
}
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Choose what you are told about, and how. Nothing here is on by default except team
notifications to the app, which you can mute per team below.
</p>
<Streams
streams={streams}
subscribed={subscribed}
onSave={saveStreams}
busy={saving.streams}
msg={notes.streams}
error={notes.streamsError}
/>
<Teams
teams={teams}
onSave={saveTeams}
busy={saving.teams}
msg={notes.teams}
error={notes.teamsError}
/>
</div>
)
}

View File

@@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) {
}
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -47,6 +48,7 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
// with `order: 0`.
export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
@@ -56,6 +58,7 @@ export const NAV = [
const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
}
function moduleTitle(baseNav, pathname) {

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { api } from '../../api/client.js'
// The landing page for the unsubscribe link in a Team notification email
// (TEAMS.md §6.4).
//
// **Public, and it must be**: the person reading it is in their mail client, not
// signed in, and an unsubscribe that first demands a login is one most people do
// not complete. The token in the path is what stands in for the session.
//
// **The page POSTs; the link the user clicked was a GET.** A GET must not mutate —
// mail clients and security scanners follow links in messages, and one that did
// would silently mute Teams nobody asked to leave. So the link lands here, this
// runs one POST, and the API route that shares the path answers GET with a
// redirect to exactly this page.
//
// **It says the same thing whatever the token was.** A page that distinguished a
// valid token from a forged one would be an oracle for which (user, Team) pairs
// exist, on a surface with no session behind it. The server always answers 200 and
// this always says the same sentence.
export default function Unsubscribe() {
const { token } = useParams()
const [state, setState] = useState('working')
// React 18 StrictMode mounts an effect twice in development. The POST is
// idempotent (it sets a boolean), so a second call is harmless — but it is
// still a second request for no reason, and the guard keeps the network panel
// honest for anyone debugging this page.
const fired = useRef(false)
useEffect(() => {
if (fired.current) return
fired.current = true
api.unsubscribeTeam(token)
.then(() => setState('done'))
// A network failure is the ONE case worth distinguishing, because it is the
// one where trying again helps. A rejected token is not: the server does not
// tell us, deliberately.
.catch(() => setState('failed'))
}, [token])
return (
<PublicLayout section="website" shell="narrow">
<PageHeader eyebrow="Notifications" title="Unsubscribe" />
{state === 'working' && <p className="sans dim">One moment</p>}
{state === 'done' && (
<>
<p className="sans" style={{ color: 'var(--ink)' }}>
You will not receive further notification emails about this team.
</p>
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
This muted the team rather than switching off your account&rsquo;s email, so your other
teams are unaffected. You can turn it back on any time under{' '}
<Link to="/account/notifications">notification settings</Link>.
</p>
</>
)}
{state === 'failed' && (
<p className="sans" style={{ color: 'var(--ink)' }}>
We could not reach the site to record that. Please try the link again, or change the
setting yourself under <Link to="/account/notifications">notification settings</Link>.
</p>
)}
</PublicLayout>
)
}

View File

@@ -0,0 +1,87 @@
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { api } from '../src/api/client.js'
// The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6).
//
// There is no DOM in this runner, so what is asserted here is the WIRE — which is
// where this feature's client-side mistakes actually live. Two of them have
// already been made once in this repo and are recorded rather than re-derived:
//
// 1. **A PUT-the-whole-set body must always carry its array**, empty included.
// `docs/android/PLAN.md` §11: a DTO field with a default is dropped by
// kotlinx when it equals that default, so "clear the last entry" arrives as a
// body with no array at all and 400s. The web client has no such
// serialisation quirk, but it shares the endpoint's contract, and a test that
// pins the shape here is what keeps the two clients honest about the same
// rule.
// 2. **The unsubscribe call is a POST**, not the GET the link in the mail was.
// A GET that mutated would be triggered by every mail-client link scanner.
let calls
const realFetch = global.fetch
function reply(body = {}) {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify(body),
}
}
beforeEach(() => {
calls = []
global.fetch = async (url, opts = {}) => {
calls.push({ url, opts })
return reply({ teams: [], streams: [], ok: true })
}
})
afterEach(() => { global.fetch = realFetch })
const body = (i = 0) => JSON.parse(calls[i].opts.body)
test('the per-Team preference endpoints sit under /auth/me, not /player', async () => {
await api.teamNotificationPrefs()
// Role-agnostic self-service, the same rule that put the Team forum under
// /player rather than behind a staff gate: staff are a superset of players and
// manage their own notifications like anyone else.
assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/)
assert.equal(calls[0].opts.method ?? 'GET', 'GET')
})
test('saving preferences PUTs the whole set under a `teams` key', async () => {
await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }])
assert.equal(calls[0].opts.method, 'PUT')
assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] })
})
test('clearing every preference still sends the array, never an absent key', async () => {
await api.setTeamNotificationPrefs([])
assert.deepEqual(body(), { teams: [] })
assert.equal('teams' in body(), true)
})
test('the same rule holds for the stream subscriptions beside them', async () => {
await api.setNotificationSubscriptions([])
assert.deepEqual(body(), { streams: [] })
})
test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => {
await api.unsubscribeTeam('1.7.3.abcDEF')
assert.equal(calls[0].opts.method, 'POST')
assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/)
})
test('a token with url-unsafe characters is encoded rather than pasted in', async () => {
await api.unsubscribeTeam('a/b c')
assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/)
})
test('the streams catalog and subscriptions are separate reads', async () => {
await api.notificationStreams()
await api.notificationSubscriptions()
assert.match(calls[0].url, /\/notifications\/streams$/)
assert.match(calls[1].url, /\/notifications\/subscriptions$/)
})