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
1 change: 1 addition & 0 deletions infrastructure/eid-wallet/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from "./bindingDocHash";
export * from "./socialBinding";
export * from "./portal";
export * from "./identityLabels";
export * from "./pendingSocialRequest";
61 changes: 61 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/pendingSocialRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
type BindingDocParsed,
fetchUnsignedSocialDocs,
} from "./socialBinding";

/**
* Requests the user closed without answering, for this app session. Shared
* because the home screen and the ePassport sheet both prompt for the same
* documents, so closing one must not leave the other still asking.
*/
const dismissedDocIds = new Set<string>();

export function dismissSocialRequest(docId: string): void {
dismissedDocIds.add(docId);
}

export function dismissedSocialRequests(): ReadonlySet<string> {
return dismissedDocIds;
}

export interface PendingSocialRequest {
docId: string;
parsed: BindingDocParsed;
signerEname: string;
}

/**
* The pending social connection request to prompt for next, or null.
*
* Oldest first, so a backlog is worked through in the order it arrived.
* `dismissedDocIds` holds the requests the user closed without answering;
* they stay in the vault and in the bindings list, they just don't prompt
* again. Envelopes fetchUnsignedSocialDocs hides stay hidden.
*/
export async function findPendingSocialRequest(
ownGqlUrl: string,
callerEname: string,
dismissedDocIds: ReadonlySet<string> = new Set<string>(),
): Promise<PendingSocialRequest | null> {
const edges = await fetchUnsignedSocialDocs(ownGqlUrl, callerEname);

let oldest: PendingSocialRequest | null = null;
let oldestSentAt = "";

for (const edge of edges) {
if (dismissedDocIds.has(edge.node.id)) continue;
const parsed = edge.node.parsed;
const signature = parsed?.signatures?.[0];
if (!parsed || !signature?.signer) continue;
const sentAt = signature.timestamp ?? "";
if (oldest !== null && sentAt >= oldestSentAt) continue;
oldest = {
docId: edge.node.id,
parsed,
signerEname: signature.signer,
};
oldestSentAt = sentAt;
}

return oldest;
}
63 changes: 63 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ vi.mock("$env/static/public", () => ({
PUBLIC_REGISTRY_URL: "https://registry.test/",
}));

import { findPendingSocialRequest } from "./pendingSocialRequest";
import {
CANCEL_NOT_PENDING,
ENAME_NOT_FOUND,
Expand All @@ -20,6 +21,7 @@ import {

const ME = "@me";
const BOB = "@bob";
const CAROL = "@carol";

interface Sig {
signer: string;
Expand Down Expand Up @@ -486,3 +488,64 @@ describe("resolveVaultUri", () => {
);
});
});

describe("choosing the request to prompt for", () => {
beforeEach(() => {
vaults.set(ME, [
doc("P1", ME, [CAROL, ME], [{ signer: CAROL, timestamp: at(10) }]),
doc("P2", ME, [BOB, ME], [{ signer: BOB, timestamp: at(40) }]),
]);
});

it("takes the oldest request first", async () => {
const request = await findPendingSocialRequest(gql(ME), ME);
expect(request?.docId).toBe("P2");
expect(request?.signerEname).toBe(BOB);
expect(request?.parsed.subject).toBe(ME);
});

it("moves on to the next request when one was dismissed", async () => {
const request = await findPendingSocialRequest(
gql(ME),
ME,
new Set(["P2"]),
);
expect(request?.docId).toBe("P1");
});

it("stops prompting once every request has been dismissed", async () => {
const request = await findPendingSocialRequest(
gql(ME),
ME,
new Set(["P1", "P2"]),
);
expect(request).toBeNull();
expect(deletes).toEqual([]);
});

it("leaves a dismissed request in the vault", async () => {
await findPendingSocialRequest(gql(ME), ME, new Set(["P1", "P2"]));
expect((vaults.get(ME) as Doc[]).map((d) => d.id)).toEqual([
"P1",
"P2",
]);
});

it("does not prompt for an envelope the poll hides", async () => {
vaults.set(ME, [
doc(
"D1",
ME,
[BOB, ME],
[
{ signer: BOB, timestamp: at(190) },
{ signer: ME, timestamp: at(180) },
],
),
doc("D2", ME, [BOB, ME], [{ signer: BOB, timestamp: at(185) }]),
]);

expect(await findPendingSocialRequest(gql(ME), ME)).toBeNull();
expect(deletes).toEqual([]);
});
});
59 changes: 42 additions & 17 deletions infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
acceptSocialBinding,
capitalize,
declineSocialBinding,
dismissSocialRequest,
fetchNameFromVault,
fetchUnsignedSocialDocs,
identityFieldLabel,
Expand Down Expand Up @@ -555,38 +556,50 @@ async function confirmSocialBindingRequest() {
async function declineSocialBindingRequest() {
const docId = socialBindingPendingDocId;
const declinedDoc = socialBindingPendingDocParsed;
socialBindingAwaitingConsent = false;
socialBindingPendingDocId = null;
socialBindingPendingDocParsed = null;
socialBindingSignerName = null;
socialBindingSignerEname = null;

if (docId) {
try {
const vault = await globalState.vaultController.vault;
if (vault?.ename && vault?.uri) {
const callerEname = vault.ename.startsWith("@")
? vault.ename
: `@${vault.ename}`;
const gqlUrl = new URL("/graphql", vault.uri).toString();
await declineSocialBinding(
gqlUrl,
callerEname,
docId,
declinedDoc,
);
if (!vault?.ename || !vault?.uri) {
throw new Error(m.social_drawer_no_vault());
}
const callerEname = vault.ename.startsWith("@")
? vault.ename
: `@${vault.ename}`;
const gqlUrl = new URL("/graphql", vault.uri).toString();
await declineSocialBinding(gqlUrl, callerEname, docId, declinedDoc);
} catch (err) {
// The document is still in the vault, so keep the request on
// screen instead of resuming as if it had been declined.
console.error(
"[Social Binding] failed to delete declined doc:",
err,
);
socialBindingError =
err instanceof Error
? err.message
: m.social_drawer_error_generic();
return;
}
}

socialBindingAwaitingConsent = false;
socialBindingPendingDocId = null;
socialBindingPendingDocParsed = null;
socialBindingSignerName = null;
socialBindingSignerEname = null;
startSocialBindingPolling();
}

// Closing without answering leaves the request in the vault. Record it so the
// home screen doesn't prompt for the same one on the way back.
function dismissSocialBindingRequest() {
if (socialBindingPendingDocId) {
dismissSocialRequest(socialBindingPendingDocId);
}
closeSocialBindingDrawer();
}

function startSocialBindingPolling() {
socialBindingPolling = true;
socialBindingPollInterval = setInterval(() => {
Expand Down Expand Up @@ -710,7 +723,19 @@ onMount(async () => {
</div>
{:else if socialBindingAwaitingConsent}
<div>
<h4 class="mb-1">{m.social_drawer_request_title()}</h4>
<div class="flex items-start justify-between gap-3">
<h4 class="mb-1">{m.social_drawer_request_title()}</h4>
<button
type="button"
onclick={dismissSocialBindingRequest}
aria-label={m.common_close()}
class="w-9 h-9 rounded-full bg-black-50 flex items-center justify-center text-black-700 active:opacity-70 shrink-0"
>
<span aria-hidden="true" class="text-xl leading-none"
>×</span
>
</button>
</div>
<p class="text-black-700">
<strong
>{socialBindingSignerName ??
Expand Down
75 changes: 74 additions & 1 deletion infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ let cachedSelfDocId: string | undefined;
let cachedSocialBindingCount = 0;
let cachedSocialBindingPreview: SocialBindingDisplay[] = [];
let hasEverLoaded = false;

// Requests the user closed without answering. Module-scope so leaving /main and
// coming back doesn't prompt for them again; an app restart clears them, and
// they stay answerable from the bindings list either way.
</script>

<script lang="ts">
Expand All @@ -42,8 +46,12 @@ import * as Button from "$lib/ui/Button";
import { isPermissionGranted } from "@choochmeque/tauri-plugin-notifications-api";
import { openAppSettings } from "@tauri-apps/plugin-barcode-scanner";
import {
type PendingSocialRequest,
fetchNameFromVault,
fetchReconciledSocialBindings,
dismissSocialRequest,
dismissedSocialRequests,
findPendingSocialRequest,
resolveVaultUri,
} from "$lib/utils";
import { getCanonicalBindingDocString } from "$lib/utils/bindingDocHash";
Expand All @@ -53,7 +61,7 @@ import {
deletePersonalBinding,
loadPersonalBindings,
} from "$lib/utils/personalBinding";
import { getContext, onDestroy, onMount, tick } from "svelte";
import { getContext, onDestroy, onMount, tick, untrack } from "svelte";
import { Shadow } from "svelte-loading-spinners";
import { fly } from "svelte/transition";
import AppsMarketplace from "./components/AppsMarketplace.svelte";
Expand Down Expand Up @@ -132,6 +140,7 @@ const bindingDiagram = $derived([
let eVaultInfoOpen = $state(false);
let bindingDocsInfoOpen = $state(false);
let socialDrawerOpen = $state(false);
let pendingSocialRequest = $state<PendingSocialRequest | null>(null);
// True while loadBindingDocuments + loadPersonalIntoStore are still in flight.
// Starts as false on re-entries (cached data paints instantly) and true only
// on first-ever mount where nothing is cached yet.
Expand Down Expand Up @@ -518,13 +527,71 @@ async function loadSocialBindings(): Promise<void> {
}

function openSocialDrawer() {
pendingSocialRequest = null;
socialDrawerOpen = true;
}

async function handleSocialBound() {
await loadSocialBindings();
}

// An incoming request opens its own sheet, so only prompt when /main is in
// front of the user and nothing else is holding the screen.
function canPromptSocialRequest(): boolean {
if (!globalState || !pageReady || tourStep !== null) return false;
if (typeof document !== "undefined" && document.hidden) return false;
return !(
socialDrawerOpen ||
showNotifPrompt ||
editNameOpen ||
kycOpen ||
eVaultInfoOpen ||
bindingDocsInfoOpen
);
}

async function checkPendingSocialRequest(): Promise<void> {
if (!canPromptSocialRequest()) return;
try {
const vault = await globalState?.vaultController.vault;
if (!vault?.uri || !vault?.ename) return;
const callerEname = vault.ename.startsWith("@")
? vault.ename
: `@${vault.ename}`;
const gqlUrl = new URL("/graphql", vault.uri).toString();

const request = await findPendingSocialRequest(
gqlUrl,
callerEname,
dismissedSocialRequests(),
);
// Re-checked after the round trip: the user may have opened something
// else while it was in flight.
if (!request || !canPromptSocialRequest()) return;
pendingSocialRequest = request;
socialDrawerOpen = true;
} catch (err) {
console.warn("[main] Failed to check for social requests:", err);
}
}

function handleSocialRequestDismissed(docId: string) {
dismissSocialRequest(docId);
}

// The sheet's own poll can miss a request that lands just before the user
// closes the QR, so check again on the way out instead of leaving it to the
// 30s refresh. Requests already dismissed are skipped, so this cannot reopen
// what the user just closed.
let socialDrawerWasOpen = false;
$effect(() => {
const open = socialDrawerOpen;
untrack(() => {
if (socialDrawerWasOpen && !open) void checkPendingSocialRequest();
socialDrawerWasOpen = open;
});
});

function openSocialFullList() {
goto("/social-bindings");
}
Expand Down Expand Up @@ -885,6 +952,8 @@ onMount(() => {
// tour — defer to when the user has dismissed it. For returning users
// (tour already seen) it fires right away.
await maybeShowNotifPrompt(seen);

void checkPendingSocialRequest();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})();

const checkStatus = () => {
Expand Down Expand Up @@ -945,6 +1014,7 @@ async function refreshBindings(): Promise<void> {
loadUserInfo(),
loadBindingDocuments(),
loadPersonalIntoStore(),
checkPendingSocialRequest(),
]);
} catch (err) {
console.warn("[main] passive refresh failed:", err);
Expand Down Expand Up @@ -1179,7 +1249,10 @@ async function refreshBindings(): Promise<void> {
<SocialBindingDrawer
bind:isOpen={socialDrawerOpen}
{globalState}
request={pendingSocialRequest}
dismissedIds={dismissedSocialRequests()}
onbound={handleSocialBound}
ondismiss={handleSocialRequestDismissed}
/>

<EditNameSheet
Expand Down
Loading
Loading