import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { AlertCircle, Link2, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert.tsx"; import { Button } from "../../../components/ui/button.tsx"; import { EmptyState } from "../../../components/ui/empty-state.tsx"; import { useAuthServiceContext } from "../../../services"; import { authSessionId, credentials } from "../../../services/authStorage"; import { HttpError } from "../../../services/HttpService.tsx"; import type { RecordsCallScope } from "../../../services/RecordsService.tsx"; import { environment } from "../../../environments/environment"; import { CONNECTION_START_LIFETIME_MS, rememberConnectionStart, sessionOriginOf, personalConnectionAttempts, retirePersonalConnectionAttempt, type PersonalConnectionBinding, type PersonalConnectionStart } from "../../../utils/connectionReturn"; import { PersonalConnectionUnconfirmed, personalOperationId, type PersonalAttempt, type PersonalConnection } from "../../../services/PersonalRecordsService"; import type { RecordsConnection, RecordsIssuer, SwitchedOff } from "../../../services/RecordsConnectionsService.tsx"; import { markPracticeLinkDecision, practiceLinkDecision, retirePracticeLinkDecision, type LinkFacts, type PracticeLinkAnswer, type PracticeLinkRequest, type PracticeLinkReview } from "../../../services/PatientConnectionsService.tsx"; import { formatDateTime } from "../../../utils/dateUtils"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { TIMED_OUT_MAY_HAVE_SAVED, isSaveRetired, isSaveTimedOut, useSaveScope, type SaveOperation } from "../../../utils/saveOperation"; import { REPLACED_IN_USE, REPLACES_EARLIER, REVOCATION_WORDS, SWITCH_OFF_UNCONFIRMED, TOKENS_DELETED, TOKENS_NOT_DELETED, connectionStateLabel, connectionRecoveryWords, readableTypes, replacedInUse, systemName, unusableGrantLine, withdrawalOwed, } from "./connectionWords.ts"; import "./RecordsPage.css"; const NOT_STARTED = "That system could not be connected. Nothing was started."; const STALE_LINK = "The link between this connection and you needs to be made again. Connect again before reading records."; const NOT_KEPT = "This browser could not keep track of the connection, so nothing was started. Allow site storage and try again."; const LINKED = "This connection is linked to you."; const LINK_CHANGED = "These details changed since you looked. Review them again before confirming."; const LINK_UNCONFIRMED = "The link result is unconfirmed. Check the link before confirming again."; const LINK_NOT_KEPT = "This browser could not keep track of the link, so nothing was sent. Allow site storage and try again."; const LINK_REFUSED = "This connection could not be linked."; const LINK_HOLDS = { "reconnect-required": STALE_LINK, "identity-conflict": "The other system's person does not match your record here, so this connection cannot be linked.", "facts-missing": "The other system did not return enough to compare, so this connection cannot be linked yet.", } as const; // Refusals that settle the decision: another one already holds the link, or its write lost to another. const SETTLED_REFUSALS = ["already-linked", "link-conflict"]; const factsLine = (facts: LinkFacts) => (facts.names.join(", ") || "No name returned") + " · born " + (facts.birthDate || "not returned"); // The server answers a start from a page whose sign-in cannot come back here with its own portal entry instead. const handoffOf = (failure: unknown): string | undefined => { if (!(failure instanceof HttpError) || failure.status !== 409) return undefined; const body = failure.body as { code?: unknown; entryUrl?: unknown } | undefined; return body?.code === "connection-start-origin-required" && typeof body.entryUrl === "string" && /^https:\/\//i.test(body.entryUrl) ? body.entryUrl : undefined; }; /** Whose connections a screen shows, what they may do, and the words and calls that fit them. */ export interface ConnectionsAudience { /** The signed-in person as a connection names its owner, e.g. "Practitioner/p-1". */ me: string | undefined; personal?: boolean; checkAttempt?: (attemptId: string, scope: RecordsCallScope) => Promise; connectionPage?: (scope: RecordsCallScope, cursor?: string) => Promise<{ connections: PersonalConnection[]; cursor?: string }>; /** Whether this person may use connections here at all; without it nothing is asked of the server. */ mayWork: boolean; refusal: string; /** Whether they may switch off a connection someone else made (a practice administrator). */ mayOffOthers: boolean; title: string; sub: string; aside?: ReactNode; noIssuers: string; issuersLabel: string; connectionsLabel: string; confirmOff: string; listConnections: (scope: RecordsCallScope) => Promise; listIssuers: (scope: RecordsCallScope) => Promise; /** Connect again names the connection it replaces. */ startConnection: (issuerId: string, scope: RecordsCallScope, replacesConnectionId?: string, preparedAttemptId?: string) => Promise<{ authorizeUrl: string; attemptId?: string }>; /** The route the other system returns this audience to, kept with the start. */ returnPath: string; disconnectConnection: (connectionId: string, scope: RecordsCallScope, operationId?: string) => Promise; /** A patient's evidence and confirm step for their own connection at this practice; absent, none is offered. */ confirmLink?: (link: PracticeLinkRequest, scope: RecordsCallScope) => Promise; } // Connections to other systems' records. The server keeps every secret: this screen lists what it answers, asks it to // start a connection and follows only the https address it answers, and switches one off only after asking. Each read // and write belongs to the viewer and practice it started for. const ConnectionsScreen = ({ audience }: { audience: ConnectionsAudience }) => { const { currentOrganization } = useAuthServiceContext(); const { listConnections, listIssuers, startConnection, disconnectConnection, mayWork, me } = audience; const organizationId = audience.personal ? "" : currentOrganization?.id; // Viewer, practice and sign-in: an answer started under another of these is never shown here. const owner = (me ?? "") + "|" + (organizationId ?? "") + "|" + (authSessionId() ?? "") + "|" + environment.sessionPortal + "|" + window.location.origin + "|" + String(audience.personal ?? false); // Retired with its viewer or sign-in: nothing it started is still shown as running. const lives = useSaveScope(owner, () => { setSwitching(undefined); setConfirming(undefined); showLink(undefined); pendingRows.current.clear(); }); const lastRead = useRef(); const run = useRef(0); const openings = useRef(0); const switches = useRef(0); const latestFor = useRef(new Map()); // Each row's mutations in order: moved on when a switch-off is admitted and when its result or failure is shown, // with the rows whose switch-off is still out. A list publishes a row only if neither happened since it began. const generations = useRef(new Map()); const pendingRows = useRef(new Set()); const advance = (connectionId: string) => generations.current.set(connectionId, (generations.current.get(connectionId) ?? 0) + 1); const [cursor, setCursor] = useState(); const [pendingAttempt, setPendingAttempt] = useState(); const [attemptNote, setAttemptNote] = useState(); const [connections, setConnections] = useState([]); const [issuers, setIssuers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [starting, setStarting] = useState(); const [startError, setStartError] = useState(); const [handoff, setHandoff] = useState(); const [confirming, setConfirming] = useState(); // The one link being reviewed or sent, and only its own operation may change it. type LinkStep = { connectionId: string; op: number; review?: PracticeLinkReview; changed?: boolean; sending?: boolean; unknown?: boolean; note?: string; done?: boolean }; const [linking, setLinking] = useState(); const linkNow = useRef(); const linkOps = useRef(0); const showLink = (step?: LinkStep) => { linkNow.current = step; setLinking(step); }; // Each switch-off is its own operation: only it may end its spinner or leave its note, never an older one for the same row. const [switching, setSwitching] = useState<{ connectionId: string; op: number }>(); // Notes are each row's own. One that says its result was not stored stays until a list says what was, or a later // stored result of its own row does; another row's operation never clears it. type SwitchNotes = Record; const [switchNotes, showSwitchNotes] = useState({}); // Kept in a ref too, so a list settling rows reads the notes as they are now, not as some render saw them. const notesNow = useRef({}); const setSwitchNotes = (update: SwitchNotes | ((now: SwitchNotes) => SwitchNotes)) => { notesNow.current = typeof update === "function" ? update(notesNow.current) : update; showSwitchNotes(notesNow.current); }; const noteFor = (connectionId: string, note?: { text: string; recheck?: boolean }) => setSwitchNotes((now) => { const { [connectionId]: _old, ...rest } = now; return note ? { ...rest, [connectionId]: note } : rest; }); const personalBinding = (): PersonalConnectionBinding | undefined => { const view = credentials().current(), epoch = authSessionId(); const origin = view.state === 'signed-in' ? sessionOriginOf(view.session.tuple.accessToken) : undefined; if (!audience.personal || !mayWork || !me?.startsWith('Patient/') || !epoch || !origin || view.state !== 'signed-in' || view.session.tuple.expiresAt <= Date.now()) return undefined; return { scope: { kind: 'personal', patientId: me.slice('Patient/'.length) }, actor: me, epoch, sessionOrigin: origin, portal: environment.sessionPortal, clientId: view.session.tuple.clientId, returnOrigin: window.location.origin, returnPath: audience.returnPath }; }; const ownedAttempts = () => { const binding = personalBinding(); return binding ? personalConnectionAttempts(binding) : []; }; useEffect(() => { setConnections([]); setIssuers([]); setStarting(undefined); setStartError(undefined); setHandoff(undefined); setConfirming(undefined); setSwitching(undefined); setSwitchNotes({}); showLink(undefined); generations.current.clear(); pendingRows.current.clear(); setCursor(undefined); setPendingAttempt(ownedAttempts()[0]?.attemptId); setAttemptNote(undefined); }, [owner]); const reload = useCallback(async (pageCursor?: string) => { const mine = ++run.current; lastRead.current?.retire(); if (!mayWork) { setLoading(false); return; } const life = lives.start(); lastRead.current = life; const scope = { admit: life.admit, signal: life.transport.signal, organizationId }; setLoading(true); setError(undefined); // Where each row stood as this read began: a row switched off since, or still being switched off, is not this read's. const seen = new Map(generations.current); const outAtStart = new Set(pendingRows.current); try { const [page, registered] = await life.step(() => Promise.all([ audience.connectionPage ? audience.connectionPage(scope, pageCursor) : listConnections(scope).then(connections => ({ connections, cursor: undefined })), listIssuers(scope), ])); const listed = page.connections; if (run.current !== mine || !life.belongs()) return; const newer = (id: string) => outAtStart.has(id) || pendingRows.current.has(id) || (generations.current.get(id) ?? 0) !== (seen.get(id) ?? 0); const listedIds = new Set(listed.map((row) => row.connectionId)); // A row left out proves nothing about a result still unconfirmed, nor about a newer one: those rows stay as shown. const keptIfLeftOut = (id: string) => newer(id) || Boolean(notesNow.current[id]?.recheck); // Rows this list installs, decided before it moves them on: each is newer than any answer still out for it. const settles = new Set(listed.map((row) => row.connectionId).filter((id) => !newer(id))); const installed = pageCursor ? new Set() : settles; setCursor(page.cursor); setConnections((now) => pageCursor ? [...now, ...listed.filter(row => !now.some(shown => shown.connectionId === row.connectionId))] : [ ...listed.map((row) => (installed.has(row.connectionId) ? row : now.find((shown) => shown.connectionId === row.connectionId) ?? row)), ...now.filter((shown) => !listedIds.has(shown.connectionId) && keptIfLeftOut(shown.connectionId)), ]); // What the list says is stored settles the rows it lists, unless they moved on after it began. setSwitchNotes((now) => Object.fromEntries(Object.entries(now).filter(([id, note]) => !(note.recheck && settles.has(id))))); installed.forEach(advance); setIssuers(registered); } catch (failure) { if (run.current !== mine || isSaveRetired(failure)) return; console.error("Error loading records connections:", failure); setError(relayServerFailure(failure, "Connections could not be loaded.")); } finally { if (life.belongs() && run.current === mine) setLoading(false); } }, [listConnections, listIssuers, lives, mayWork, organizationId, audience.connectionPage]); useEffect(() => { void reload(); return () => { run.current += 1; }; }, [reload, owner]); const connect = async (issuer: RecordsIssuer, replacing?: RecordsConnection) => { const life = lives.start(); setStarting(replacing ? "row:" + replacing.connectionId : issuer.issuerId); setStartError(undefined); setHandoff(undefined); // The session and opening this start belongs to, captured before the request; the return is held against it. const epoch = authSessionId(); const view = credentials().current(); let prepared: PersonalConnectionStart | undefined; try { if (audience.personal) { const binding = personalBinding(); if (!binding) { setStarting(undefined); setStartError(NOT_KEPT); return; } prepared = { ...binding, schema: 2, attemptId: personalOperationId(), opening: owner + '#' + String(++openings.current), expiresAt: Date.now() + CONNECTION_START_LIFETIME_MS }; life.admit(); if (!rememberConnectionStart(prepared)) { setStarting(undefined); setStartError(NOT_KEPT); return; } setPendingAttempt(prepared.attemptId); } const scope = { admit: life.admit, signal: life.transport.signal, organizationId }; const { authorizeUrl, attemptId } = await life.step(() => audience.personal ? startConnection(issuer.issuerId, scope, replacing?.connectionId, prepared!.attemptId) : startConnection(issuer.issuerId, scope, replacing?.connectionId), true); // Only an https address is followed; the server's answer is the vendor's sign-in page and nothing else. if (typeof authorizeUrl !== "string" || !/^https:\/\//i.test(authorizeUrl) || typeof attemptId !== "string" || !attemptId || !epoch || view.state !== "signed-in" || !me || (!audience.personal && !organizationId)) { setStarting(undefined); setStartError(NOT_STARTED); return; } life.admit(); const kept = audience.personal ? attemptId === prepared!.attemptId && rememberConnectionStart(prepared!) : rememberConnectionStart({ schema: 1, attemptId, epoch, sessionOrigin: sessionOriginOf(view.session.tuple.accessToken), actor: me, practice: organizationId!, portal: environment.sessionPortal, clientId: view.session.tuple.clientId, returnPath: audience.returnPath, opening: owner + "#" + String(++openings.current), expiresAt: Date.now() + CONNECTION_START_LIFETIME_MS, }); // Without the start kept here, the return could never be matched to this sign-in, so the browser does not leave. if (!kept) { setStarting(undefined); setStartError(NOT_KEPT); return; } window.location.assign(authorizeUrl); } catch (failure) { if (isSaveRetired(failure)) return; setStarting(undefined); if (failure instanceof PersonalConnectionUnconfirmed) { setPendingAttempt(failure.attemptId); setStartError(failure.message); return; } const entry = handoffOf(failure); if (entry) { setHandoff(entry); return; } setStartError(isSaveTimedOut(failure) ? NOT_STARTED : relayServerFailure(failure, NOT_STARTED)); } }; const switchOff = async (connection: RecordsConnection) => { const life = lives.start(); const op = ++switches.current; latestFor.current.set(connection.connectionId, op); advance(connection.connectionId); pendingRows.current.add(connection.connectionId); setConfirming(undefined); setSwitching({ connectionId: connection.connectionId, op }); // Its own earlier failure goes as it tries again; a result still unconfirmed stays until something settles it. setSwitchNotes((now) => (now[connection.connectionId]?.recheck ? now : Object.fromEntries(Object.entries(now).filter(([id]) => id !== connection.connectionId)))); try { const { connection: off, unconfirmed } = await life.step(() => disconnectConnection(connection.connectionId, { admit: life.admit, signal: life.transport.signal, organizationId }, (connection as PersonalConnection).disconnectOperationId), true); if (latestFor.current.get(connection.connectionId) !== op) return; advance(connection.connectionId); pendingRows.current.delete(connection.connectionId); setConnections((now) => now.map((row) => (row.connectionId === off.connectionId ? off : row))); noteFor(off.connectionId, unconfirmed ? { text: SWITCH_OFF_UNCONFIRMED, recheck: true } : undefined); } catch (failure) { if (isSaveRetired(failure) || latestFor.current.get(connection.connectionId) !== op) return; advance(connection.connectionId); pendingRows.current.delete(connection.connectionId); const text = isSaveTimedOut(failure) ? TIMED_OUT_MAY_HAVE_SAVED : relayServerFailure(failure, "This connection could not be switched off."); // A failure settles nothing: a result still unconfirmed from before keeps its Check again. setSwitchNotes((now) => ({ ...now, [connection.connectionId]: { text, ...(now[connection.connectionId]?.recheck ? { recheck: true } : {}) } })); } finally { if (life.belongs()) setSwitching((now) => (now?.op === op ? undefined : now)); } }; // The patient's own link of a completed connection: the review first, then their explicit confirmation of exactly // what was shown, both under one decision that is kept until the server settles it. const sendLink = async (connection: RecordsConnection, confirmation?: PracticeLinkReview) => { const confirmLink = audience.confirmLink; if (!confirmLink || connection.owner !== me) return; const { connectionId } = connection; const key = owner + "#" + connectionId; const decision = practiceLinkDecision(key); if (!decision) { showLink({ connectionId, op: ++linkOps.current, note: LINK_NOT_KEPT }); return; } const { operationId, uncertain: wasUncertain } = decision; // A confirmation may start a write, so it is recorded as uncertain before it is sent, or not sent at all. if (confirmation && !markPracticeLinkDecision(key, { operationId, uncertain: true })) { showLink({ connectionId, op: ++linkOps.current, note: LINK_NOT_KEPT }); return; } const restore = () => { if (confirmation && !wasUncertain) markPracticeLinkDecision(key, { operationId, uncertain: false }); }; const life = lives.start(); const shown = linkNow.current?.connectionId === connectionId ? linkNow.current.review : undefined; const step: LinkStep = { connectionId, op: ++linkOps.current, sending: true, ...(confirmation ? { review: shown } : {}) }; showLink(step); // The row as it stood when this was sent: a switch-off or list since then is newer than this answer. const rowAtSend = generations.current.get(connectionId) ?? 0; const sent: PracticeLinkRequest = { connectionId, operationId, ...(confirmation ? { evidenceDigest: confirmation.evidenceDigest, confirm: confirmation.outcome === "match" ? "self" : "self-name-reviewed" } : {}) }; try { const answer = await life.step(() => confirmLink(sent, { admit: life.admit, signal: life.transport.signal, organizationId }), true); if (linkNow.current?.op !== step.op || !life.belongs()) return; // A review after a confirmation means what was shown no longer holds: it is shown afresh, never confirmed for them. if (answer.kind === "review") { restore(); showLink({ connectionId, op: step.op, review: answer.review, changed: Boolean(confirmation) }); return; } // A hold says only that this request could not link; an earlier write still uncertain keeps its decision. if (answer.kind === "hold") { if (wasUncertain) restore(); else retirePracticeLinkDecision(key, operationId); showLink({ connectionId, op: step.op, note: LINK_HOLDS[answer.code], unknown: wasUncertain }); if (answer.code === "reconnect-required") void reload(); return; } retirePracticeLinkDecision(key, operationId); const settled = answer.connection; const current = (generations.current.get(connectionId) ?? 0) === rowAtSend && !pendingRows.current.has(connectionId); if (current && settled && settled.owner === me) { advance(connectionId); setConnections((now) => now.map((row) => (row.connectionId === connectionId ? settled : row))); } else if (current) void reload(); showLink({ connectionId, op: step.op, note: LINKED, done: true }); } catch (failure) { if (isSaveRetired(failure) || linkNow.current?.op !== step.op) return; // Only the server's own typed refusal answers this request; a gateway's, or none, leaves any write uncertain. const served = failure instanceof HttpError && failure.status < 500 && failure.awsErrorType === undefined && !isSaveTimedOut(failure); const code = served ? (failure.body as { code?: unknown } | undefined)?.code : undefined; if (served && typeof code === "string" && SETTLED_REFUSALS.includes(code)) { retirePracticeLinkDecision(key, operationId); void reload(); showLink({ connectionId, op: step.op, note: relayServerFailure(failure, LINK_REFUSED) }); return; } if (failure instanceof HttpError && failure.status < 500 && !isSaveTimedOut(failure)) { restore(); showLink({ connectionId, op: step.op, note: relayServerFailure(failure, LINK_REFUSED), unknown: wasUncertain }); return; } showLink({ connectionId, op: step.op, note: LINK_UNCONFIRMED, unknown: true }); } }; const checkAttempt = async () => { if (!pendingAttempt || !audience.checkAttempt) return; const life = lives.start(); try { const attempt = pendingAttempt; const held = ownedAttempts().find(start => start.attemptId === attempt); const answer = await life.step(() => audience.checkAttempt!(attempt, { admit: life.admit, signal: life.transport.signal, organizationId })); if (held && answer.connection && (answer.connection.owner !== held.actor || answer.connection.localPatient !== held.scope.patientId)) throw new Error('The attempt answer belongs to another patient'); setAttemptNote('Connection attempt: ' + answer.status + '. Checking does not contact the other system again.'); if (answer.attemptId === attempt && ['connected', 'refused', 'expired', 'disconnected', 'needs-reconnect', 'adoption-failed'].includes(answer.status)) { if (held) retirePersonalConnectionAttempt(held); setPendingAttempt(now => now === attempt ? ownedAttempts()[0]?.attemptId : now); void reload(); } } catch (error) { if (!isSaveRetired(error)) setAttemptNote(relayServerFailure(error, 'The connection attempt is still unconfirmed.')); } }; const mayStartAny = mayWork && !starting && !pendingAttempt; return (

{audience.title}

{audience.sub}

{audience.aside}
{!mayWork &&

{audience.refusal}

} {mayWork && error && !loading && ( {error} )} {mayWork && loading &&

} {mayWork && !loading && !error && ( <> {issuers.length === 0 &&

{audience.noIssuers}

} {issuers.length > 0 && (
    {issuers.map((issuer) => (
  • {systemName(issuer)} {issuer.issuerDisplay}
  • ))}
)} {startError && ( {startError} )} {pendingAttempt && audience.checkAttempt && } {attemptNote &&

{attemptNote}

} {cursor && } {handoff && (

Connecting another system starts at your practice's portal, where the sign-in can come back to finish it. Nothing was started here. Sign in there and press Connect again.

Continue at your practice's portal

)} {connections.length === 0 && ( )} {connections.length > 0 && (
    {connections.map((connection) => { const types = readableTypes(connection.grantedScopes); const mayAct = connection.owner === me || audience.mayOffOthers; const mayOff = connection.status !== "disconnected" && mayAct; const mayFinish = withdrawalOwed(connection) && mayAct; const note = switchNotes[connection.connectionId]; const busy = switching?.connectionId === connection.connectionId; // Replaced by one in use: switching it off finishes that, and it is not connected again. const replaced = replacedInUse(connection, connections); const staleLink = connection.status === "connected" && connection.identity?.action === "reconnect"; const recovery = replaced ? REPLACED_IN_USE : staleLink ? STALE_LINK : connectionRecoveryWords(connection); // Connect again is the owner's, through the registration the row was made with while it is still offered. const again = (connection.status === "needs-reconnect" || staleLink) && connection.owner === me && !replaced ? issuers.find((each) => each.issuerId === connection.issuerId) : undefined; const mayLink = Boolean(audience.confirmLink) && connection.status === "connected" && connection.identity?.action === "confirm" && connection.owner === me; const link = linking?.connectionId === connection.connectionId ? linking : undefined; return (
  • {systemName(connection)} {replaced ? "Replaced, not switched off yet" : connectionStateLabel(connection)} {recovery && {recovery}} {connection.replaces && {REPLACES_EARLIER}} {connection.issuerDisplay} {types.length > 0 && {(connection.status === "connected" && !connection.renewing && !connection.standing ? "Can read: " : "Granted record types: ") + types.join(", ")}} {unusableGrantLine(connection.grantedScopes) && {unusableGrantLine(connection.grantedScopes)}} {(connection.owner === me ? "Connected by you" : "Connected by someone else at this practice") + " - " + formatDateTime(connection.connectedAt)} {connection.disconnectedAt ? " - Switched off " + formatDateTime(connection.disconnectedAt) : ""} {connection.status === "disconnected" && connection.revocation && ( {REVOCATION_WORDS[connection.revocation]} )} {connection.cleanup === "tokens-not-deleted" && {TOKENS_NOT_DELETED}} {connection.status === "disconnected" && connection.cleanup === "tokens-deleted" && {TOKENS_DELETED}} {note && {note.text}} {note?.recheck && ( )} {again && ( )} {mayLink && !link?.review && ( )} {mayLink && link?.review && (
    {link.changed &&

    {LINK_CHANGED}

    }

    The other system names this person: {factsLine(link.review.source)}

    Your record here: {factsLine(link.review.local)}

    {link.review.outcome === "name-review" &&

    The names differ. Confirm only if both are you.

    } {" "}
    )} {link?.note && {link.note}} {mayOff && confirming !== connection.connectionId && ( )} {mayFinish && ( )} {mayOff && confirming === connection.connectionId && ( {audience.confirmOff}{" "} {" "} )}
  • ); })}
)} )}
); }; export default ConnectionsScreen;