From
{{
- givenByProjectFunction()
+ givenByProject()
? providerProjectName
: // check for DID because name could be "Unnamed"
- givenByPersonFunction() && giverDid
+ givenByPerson() && giverDid
? giverName
: "someone not named"
}}
@@ -42,9 +42,10 @@
to
{{
- givenToProject
+ givenToProject()
? fulfillsProjectName
- : givenToRecipient
+ : // check for DID because name could be "Unnamed"
+ givenToPerson() && recipientDid
? recipientName
: "someone not named"
}}
@@ -114,9 +115,9 @@
diff --git a/src/views/HelpView.vue b/src/views/HelpView.vue
index 11cda338..ac2aa9d6 100644
--- a/src/views/HelpView.vue
+++ b/src/views/HelpView.vue
@@ -442,9 +442,9 @@
browser window and look at the version there.
- Close all tabs that have Time Safari open; it can be difficult to find them all,
+ Close all tabs that have this site open; it can be difficult to find them all,
and you may have to close all your tabs. In addition, it may be running as an
- installed app, so look for any Time Safari app that may be running outside a browser.
+ installed app, so look for any app that may be running outside a browser.
There may be a problem with your identity. Go to the Identity
@@ -467,7 +467,7 @@
Search
for instructions for other browsers.
- Then reload Time Safari.
+ Then reload the page.
@@ -552,8 +552,8 @@
Contact us at
- info@TimeSafari.app{{ SUPPORT_EMAIL }}
@@ -591,7 +591,7 @@ import { copyToClipboard } from "../services/ClipboardService";
import * as Package from "../../package.json";
import QuickNav from "../components/QuickNav.vue";
-import { APP_SERVER, NotificationIface } from "../constants/app";
+import { APP_SERVER, NotificationIface, SUPPORT_EMAIL } from "../constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { QRNavigationService } from "@/services/QRNavigationService";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
@@ -643,7 +643,7 @@ export default class HelpView extends Vue {
showVerifiable = false;
APP_SERVER = APP_SERVER;
- // Capacitor reference removed - using QRNavigationService instead
+ SUPPORT_EMAIL = SUPPORT_EMAIL;
/**
* Initialize notification helpers
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index c2f74207..2aa28e7e 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -167,18 +167,12 @@ Raymer * @version 1.0.0 */
Latest Activity
-
@@ -67,7 +69,8 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
-import MembersList from "../components/MembersList.vue";
+import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
+import MeetingMembersList from "../components/MeetingMembersList.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import { encryptMessage } from "../libs/crypto";
import {
@@ -78,12 +81,14 @@ import {
import { generateSaveAndActivateIdentity } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { NotificationIface } from "../constants/app";
+import { AxiosErrorResponse } from "@/interfaces";
@Component({
components: {
QuickNav,
TopMessage,
- MembersList,
+ MeetingMemberMatch,
+ MeetingMembersList,
UserNameDialog,
},
mixins: [PlatformServiceMixin],
@@ -178,7 +183,7 @@ export default class OnboardMeetingMembersView extends Vue {
}
} catch (error) {
this.errorMessage =
- serverMessageForUser(error) ||
+ serverMessageForUser(error as unknown as AxiosErrorResponse) ||
"There was an error checking for that meeting. Reload or go back and try again.";
this.$logAndConsole(
"Error checking meeting: " + errorStringForLog(error),
diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue
index a3378330..1d7c91b3 100644
--- a/src/views/OnboardMeetingSetupView.vue
+++ b/src/views/OnboardMeetingSetupView.vue
@@ -7,7 +7,7 @@
- Onboarding Meeting
+ Onboarding Meeting Admin
@@ -63,8 +63,8 @@
- Share the password with the members. You can also send them the
- "shortcut page for members" link below.
+ Share the meeting name & password with the members, or send them the
+ "Page for Members" link below.
@@ -307,15 +307,251 @@
-
+
+
+
+
Matching Pairs
+
+
+
+
+
+
+
+ Do Not Pair Together
+
+
+ People in the same group will not be matched with each other.
+
+
+ Erase matches to change restrictions.
+
+
+
+
+
+
+ Loading matches…
+
+
+ -
+ Pair {{ pair.pairNumber }}
+
+ (similarity {{ pair.similarity.toFixed(2) }})
+
+ -
+ {{
+ p.decryptedContentObject?.name
+ ? p.decryptedContentObject.name
+ : "(No Name)"
+ }}
+ -
+ {{
+ p.description
+ ? p.description.substring(0, 80) +
+ (p.description.length > 80 ? "…" : "")
+ : "(No Profile)"
+ }}
+
+
+
+
+
+ No matches yet. Click "Make New Matches" to pair members by profile
+ similarity.
+
+
+
+ Not Paired ({{ unmatchedMembers.length }})
+
+
+ -
+ {{ m.name }} —
+ {{ m.reason }}
+
+
+
+
+
+
+
+
+
+
Confirm Matching
+
+
+
+ Will be matched ({{ includedMembers.length }})
+
+
+
+ {{ member.name }}
+
+
+ No participants
+
+
+
+
+
+
+ Excluded ({{ excludedDids.length }})
+
+
+
+ {{ getMemberNameByDid(did) }}
+
+
+
+
+
+
+ Do Not Pair Groups
+
+
+
+ {{
+ group.name || "Unnamed group"
+ }}:
+ {{
+ group.memberDids.map((d) => getMemberNameByDid(d)).join(", ")
+ }}
+
+
+
+
+
+ {{ previousMatchedPairs.length }} previous pair(s) will be avoided.
+
+
+
+
+ Odd number of participants ({{ includedMembers.length }}). Matching
+ requires an even number — exclude one more person or add another
+ member.
+
+
+
+
+
+
+
+
+
0;
+ }
+
+ admittedMembers: Array<{ did: string; name: string }> = [];
+
+ get includedMembers(): Array<{ did: string; name: string }> {
+ return this.admittedMembers.filter(
+ (m) => !this.excludedDids.includes(m.did),
+ );
+ }
+
+ get unmatchedMembers(): Array<{ did: string; name: string; reason: string }> {
+ if (!this.matchPairs?.length || !this.admittedMembers.length) return [];
+ const matchedDids = new Set();
+ for (const pair of this.matchPairs) {
+ for (const p of pair.participants) {
+ matchedDids.add(p.issuerDid);
+ }
+ }
+ return this.admittedMembers
+ .filter((m) => !matchedDids.has(m.did))
+ .map((m) => {
+ let reason = "not paired (odd number of participants)";
+ if (this.excludedDids.includes(m.did)) {
+ reason = "individually excluded";
+ } else {
+ const inGroup = this.doNotPairGroups.find((g) =>
+ g.memberDids.includes(m.did),
+ );
+ if (inGroup) {
+ reason = `in do-not-pair group "${inGroup.name || "Unnamed"}"`;
+ }
+ }
+ return { did: m.did, name: m.name, reason };
+ });
+ }
+
+ get excludedPairDids(): [string, string][] {
+ const pairs: [string, string][] = [];
+ for (const group of this.doNotPairGroups) {
+ for (let i = 0; i < group.memberDids.length; i++) {
+ for (let j = i + 1; j < group.memberDids.length; j++) {
+ pairs.push([group.memberDids[i], group.memberDids[j]]);
+ }
+ }
+ }
+ return pairs;
+ }
+
+ /**
+ * Computed property for selected project
+ * Returns the separately stored selected project data
+ */
+ get selectedProject(): PlanData | null {
+ return this.selectedProjectData;
+ }
+
+ /**
+ * Computed property for selected project issuer display name
+ * Uses didInfo to format the issuer name similar to ProjectCard
+ */
+ get selectedProjectIssuerName(): string {
+ if (!this.selectedProject) {
+ return "";
+ }
+ return didInfo(
+ this.selectedProject.issuerDid,
+ this.activeDid,
+ this.allMyDids,
+ this.allContacts,
+ );
+ }
+
async created() {
this.notify = createNotifyHelpers(
this.$notify as Parameters[0],
@@ -446,6 +784,12 @@ export default class OnboardMeetingView extends Vue {
// Ensure selected project is loaded if projectLink exists
await this.ensureSelectedProjectLoaded();
+ // Load pairwise matches when organizer has a meeting
+ if (this.currentMeeting?.password) {
+ await this.fetchMatchPairs();
+ }
+
+ this.loadExclusionState();
this.isLoading = false;
}
@@ -589,6 +933,12 @@ export default class OnboardMeetingView extends Vue {
);
return;
}
+ if (!this.fullName) {
+ this.$saveSettings({
+ firstName: this.newOrUpdatedMeetingInputs.userFullName,
+ });
+ }
+
if (!this.newOrUpdatedMeetingInputs.password) {
this.notify.warning(
NOTIFY_MEETING_PASSWORD_REQUIRED.message,
@@ -793,7 +1143,7 @@ export default class OnboardMeetingView extends Vue {
this.newOrUpdatedMeetingInputs = null;
if (this.currentMeeting?.password) {
- this.$router.push({
+ await this.$router.push({
name: "onboard-meeting-setup",
query: { password: this.currentMeeting?.password },
});
@@ -827,6 +1177,230 @@ export default class OnboardMeetingView extends Vue {
return "";
}
+ /**
+ * Fetch current pairwise matches (GET /api/partner/groupOnboardMatch).
+ * Any member of the meeting can fetch; organizer is the one who can POST.
+ */
+ async fetchMatchPairs(): Promise {
+ if (!this.currentMeeting?.password) return;
+ this.isLoadingMatches = true;
+ try {
+ const headers = await getHeaders(this.activeDid);
+ const response = await this.axios.get(
+ this.apiServer + "/api/partner/groupOnboardMatch",
+ { headers },
+ );
+ const pairs = response?.data?.data?.pairs ?? null;
+ let tempMatchPairs: MatchPair[] | null = null;
+ if (Array.isArray(pairs)) {
+ tempMatchPairs = [];
+ // walk through pairs and decrypt the content for each participant
+ for (const pair of pairs) {
+ for (const participant of pair.participants) {
+ try {
+ const decryptedContent = await decryptMessage(
+ participant.content,
+ this.currentMeeting?.password || "",
+ );
+ participant.decryptedContentObject = JSON.parse(decryptedContent);
+ } catch (error) {
+ this.$logAndConsole(
+ "Error decrypting participant content: " +
+ errorStringForLog(error),
+ true,
+ );
+ participant.decryptedContentObject = null;
+ }
+ }
+ tempMatchPairs.push(pair);
+ }
+ }
+ this.matchPairs = tempMatchPairs;
+ if (tempMatchPairs?.length) {
+ this.mergePairsIntoPrevious(tempMatchPairs);
+ }
+ } catch (error) {
+ this.$logAndConsole(
+ "Error fetching match pairs: " + errorStringForLog(error),
+ true,
+ );
+ // Don't overwrite matchPairs on fetch error - preserve existing data so we don't wipe
+ // good matches on transient failures (e.g. after navigating away and back).
+ this.notify.error(
+ serverMessageForUser(error as unknown as AxiosErrorResponse) ||
+ "Failed to load matches.",
+ TIMEOUTS.LONG,
+ );
+ } finally {
+ this.isLoadingMatches = false;
+ }
+ }
+
+ /**
+ * Normalize a pair of DIDs to a sorted tuple for deduplication.
+ */
+ private normalizedPair(did1: string, did2: string): [string, string] {
+ return did1 <= did2 ? [did1, did2] : [did2, did1];
+ }
+
+ /**
+ * Append pairs from a match result into previousMatchedPairs (no duplicates).
+ */
+ private mergePairsIntoPrevious(pairs: MatchPair[]): void {
+ for (const pair of pairs) {
+ if (pair.participants?.length !== 2) continue;
+ const [a, b] = pair.participants.map((p) => p.issuerDid);
+ const norm = this.normalizedPair(a, b);
+ const exists = this.previousMatchedPairs.some(
+ ([x, y]) => x === norm[0] && y === norm[1],
+ );
+ if (!exists) {
+ this.previousMatchedPairs.push(norm);
+ }
+ }
+ }
+
+ /**
+ * POST to groupOnboardMatch with optional body (excludedDids, excludedPairDids, previousPairDids).
+ * Organizer only; uses meeting for current user.
+ */
+ async postMatch(body?: {
+ excludedDids?: string[];
+ excludedPairDids?: [string, string][];
+ previousPairDids?: [string, string][];
+ }): Promise {
+ try {
+ const headers = await getHeaders(this.activeDid);
+ const response = await this.axios.post(
+ this.apiServer + "/api/partner/groupOnboardMatch",
+ body ?? {},
+ { headers },
+ );
+ const pairs = response?.data?.data?.pairs ?? null;
+ return Array.isArray(pairs) ? pairs : null;
+ } catch (error) {
+ this.$logAndConsole(
+ "Error posting group onboard match: " + errorStringForLog(error),
+ true,
+ );
+ const errorMessage = serverMessageForUser(
+ error as unknown as AxiosErrorResponse,
+ );
+ this.notify.error(
+ errorMessage || "Failed to run matching.",
+ TIMEOUTS.LONG,
+ );
+ return null;
+ }
+ }
+
+ promptPreMatchConfirm(): void {
+ this.refreshAdmittedMembers();
+ this.showPreMatchConfirm = true;
+ }
+
+ refreshAdmittedMembers(): void {
+ const membersList = this.$refs.membersList as MeetingMembersList;
+ this.admittedMembers = membersList?.getAdmittedMembers() ?? [];
+ }
+
+ cancelPreMatchConfirm(): void {
+ this.showPreMatchConfirm = false;
+ }
+
+ getMemberNameByDid(did: string): string {
+ const member = this.admittedMembers.find((m) => m.did === did);
+ return member?.name || did.substring(0, 16) + "…";
+ }
+
+ async confirmAndMatch(): Promise {
+ this.showPreMatchConfirm = false;
+ await this.postNewMatchesThenRefresh();
+ }
+
+ async postNewMatchesThenRefresh(): Promise {
+ this.isPostingMatch = true;
+ try {
+ const body: {
+ excludedDids?: string[];
+ excludedPairDids?: [string, string][];
+ previousPairDids?: [string, string][];
+ } = {};
+
+ if (this.excludedDids.length > 0) {
+ body.excludedDids = this.excludedDids;
+ }
+ if (this.excludedPairDids.length > 0) {
+ body.excludedPairDids = this.excludedPairDids;
+ }
+ if (this.previousMatchedPairs.length > 0) {
+ body.previousPairDids = this.previousMatchedPairs;
+ }
+
+ const pairs = await this.postMatch(
+ Object.keys(body).length > 0 ? body : undefined,
+ );
+ if (Array.isArray(pairs) && pairs.length > 0) {
+ const tempMatchPairs: MatchPair[] = [];
+ for (const pair of pairs) {
+ for (const participant of pair.participants) {
+ const decryptedContent = await decryptMessage(
+ participant.content,
+ this.currentMeeting?.password || "",
+ );
+ participant.decryptedContentObject = JSON.parse(decryptedContent);
+ }
+ tempMatchPairs.push(pair);
+ }
+ this.matchPairs = tempMatchPairs;
+ this.mergePairsIntoPrevious(tempMatchPairs);
+ this.notify.success("New matches generated.", TIMEOUTS.STANDARD);
+ }
+ } finally {
+ this.isPostingMatch = false;
+ }
+ }
+
+ handleEraseClick(): void {
+ if (this.isPostingMatch) {
+ this.notify.warning(
+ "Matching is currently in progress. Please wait for it to finish.",
+ TIMEOUTS.LONG,
+ );
+ return;
+ }
+ if (!this.matchPairs?.length) {
+ this.notify.warning(
+ "There are no matches to erase. Run matching first to create pairs.",
+ TIMEOUTS.LONG,
+ );
+ return;
+ }
+ this.clearMatchesThenRefresh();
+ }
+
+ async clearMatchesThenRefresh(): Promise {
+ try {
+ const headers = await getHeaders(this.activeDid);
+ await this.axios.delete(
+ this.apiServer + "/api/partner/groupOnboardMatch",
+ { headers },
+ );
+ this.matchPairs = null;
+ this.previousMatchedPairs = [];
+ } catch (error) {
+ this.$logAndConsole(
+ "Error clearing matches: " + errorStringForLog(error),
+ true,
+ );
+ this.notify.error(
+ serverMessageForUser(error as unknown as AxiosErrorResponse) ||
+ "Failed to clear matches.",
+ TIMEOUTS.LONG,
+ );
+ }
+ }
+
handleMembersError(message: string) {
this.notify.error(message, TIMEOUTS.LONG);
}
@@ -844,30 +1418,52 @@ export default class OnboardMeetingView extends Vue {
}
}
- /**
- * Computed property for selected project
- * Returns the separately stored selected project data
- */
- get selectedProject(): PlanData | null {
- return this.selectedProjectData;
+ loadExclusionState(): void {
+ if (!this.meetingGroupIdStr) return;
+ try {
+ const raw = localStorage.getItem(
+ OnboardMeetingView.EXCLUSION_STORAGE_KEY,
+ );
+ if (!raw) return;
+ const state: MeetingExclusionState = JSON.parse(raw);
+ if (state.meetingGroupId !== this.meetingGroupIdStr) {
+ localStorage.removeItem(OnboardMeetingView.EXCLUSION_STORAGE_KEY);
+ return;
+ }
+ this.excludedDids = state.excludedDids || [];
+ this.doNotPairGroups = state.doNotPairGroups || [];
+ } catch {
+ this.excludedDids = [];
+ this.doNotPairGroups = [];
+ }
}
- /**
- * Computed property for selected project issuer display name
- * Uses didInfo to format the issuer name similar to ProjectCard
- */
- get selectedProjectIssuerName(): string {
- if (!this.selectedProject) {
- return "";
- }
- return didInfo(
- this.selectedProject.issuerDid,
- this.activeDid,
- this.allMyDids,
- this.allContacts,
+ saveExclusionState(): void {
+ if (!this.meetingGroupIdStr) return;
+ const state: MeetingExclusionState = {
+ meetingGroupId: this.meetingGroupIdStr,
+ excludedDids: this.excludedDids,
+ doNotPairGroups: this.doNotPairGroups,
+ };
+ localStorage.setItem(
+ OnboardMeetingView.EXCLUSION_STORAGE_KEY,
+ JSON.stringify(state),
);
}
+ toggleExclusion(did: string): void {
+ if (this.excludedDids.includes(did)) {
+ this.excludedDids = this.excludedDids.filter((d) => d !== did);
+ } else {
+ this.excludedDids = [...this.excludedDids, did];
+ }
+ this.saveExclusionState();
+ }
+
+ handleDoNotPairGroupsUpdate(): void {
+ this.saveExclusionState();
+ }
+
/**
* Open the project link selection dialog
*/
@@ -898,20 +1494,20 @@ export default class OnboardMeetingView extends Vue {
}
/**
- * Handle dialog open event - stop auto-refresh in MembersList
+ * Handle dialog open event - stop auto-refresh in MeetingMembersList
*/
handleDialogOpen(): void {
- const membersList = this.$refs.membersList as MembersList;
+ const membersList = this.$refs.membersList as MeetingMembersList;
if (membersList) {
membersList.stopAutoRefresh();
}
}
/**
- * Handle dialog close event - start auto-refresh in MembersList
+ * Handle dialog close event - start auto-refresh in MeetingMembersList
*/
handleDialogClose(): void {
- const membersList = this.$refs.membersList as MembersList;
+ const membersList = this.$refs.membersList as MeetingMembersList;
if (membersList) {
membersList.startAutoRefresh();
}
diff --git a/src/views/StartView.vue b/src/views/StartView.vue
index e6871cae..f012cbb1 100644
--- a/src/views/StartView.vue
+++ b/src/views/StartView.vue
@@ -26,6 +26,31 @@
+
+
+
Startup Error
+
+ The app encountered a critical error during startup. This is often
+ caused by a database problem. Please send the details below to
+ {{
+ SUPPORT_EMAIL
+ }}
+ so we can help resolve it.
+
+
+
+ Error details
+
+ {{ startupError }}
+
+
+
@@ -35,10 +60,7 @@
A passkey is easy to manage, though it is less
interoperable with other systems for advanced uses.
-
+
@@ -46,7 +68,7 @@
A
new seed allows you full control over the keys,
though you are responsible for backups.
@@ -139,7 +161,7 @@
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
-import { AppString, PASSKEYS_ENABLED } from "../constants/app";
+import { AppString, PASSKEYS_ENABLED, SUPPORT_EMAIL } from "../constants/app";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { logger } from "../utils/logger";
@@ -157,10 +179,12 @@ export default class StartView extends Vue {
// Feature flags and application constants
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
+ SUPPORT_EMAIL = SUPPORT_EMAIL;
// Component state for identity generation
givenName = "";
numAccounts = 0;
+ startupError = "";
/**
* Computed property for primary action button styling
@@ -201,11 +225,26 @@ export default class StartView extends Vue {
*/
async mounted() {
try {
- // Load user settings using platform service
+ const raw = sessionStorage.getItem("startupError");
+ if (raw) {
+ sessionStorage.removeItem("startupError");
+ try {
+ const parsed = JSON.parse(raw);
+ const parts = [parsed.message, parsed.stack].filter(Boolean);
+ this.startupError = parts.length > 0 ? parts.join("\n\n") : raw;
+ logger.error("[StartView] Displaying startup error to user", parsed);
+ } catch {
+ this.startupError = raw;
+ }
+ }
+ } catch {
+ // sessionStorage or JSON parse may fail; non-critical
+ }
+
+ try {
const settings = await this.$accountSettings();
this.givenName = settings.firstName || "";
- // Load account count for display logic
this.numAccounts = await retrieveAccountCount();
logger.debug("[StartView] Component mounted", {
@@ -215,7 +254,6 @@ export default class StartView extends Vue {
});
} catch (error) {
logger.error("[StartView] Failed to load initialization data", error);
- // Continue with default behavior if settings load fails
this.givenName = "";
this.numAccounts = 0;
}
diff --git a/test-playwright/30-record-gift.spec.ts b/test-playwright/30-record-gift.spec.ts
index 90af649c..4c684502 100644
--- a/test-playwright/30-record-gift.spec.ts
+++ b/test-playwright/30-record-gift.spec.ts
@@ -106,9 +106,9 @@ test('Record something given', async ({ page }) => {
await page.waitForFunction(() => {
return !document.querySelector('.dialog-overlay');
}, { timeout: 5000 });
-
+
await page.getByRole('button', { name: 'Thank' }).click();
- await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
+ await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
@@ -130,8 +130,9 @@ test('Record something given', async ({ page }) => {
// Verify the gift we just recorded appears in the activity feed
await expect(page.getByText(finalTitle, { exact: false })).toBeVisible();
- // Click the specific gift item
- const item = page.locator('li:first-child').filter({ hasText: finalTitle });
+ // Click the specific gift item (find by title - don't assume first-child,
+ // since parallel tests or shared DB can add newer items above ours)
+ const item = page.locator('ul#listLatestActivity li').filter({ hasText: finalTitle });
await retryClick(page, item.locator('[data-testid="circle-info-link"]'));
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
// Verify we're viewing the specific gift we recorded
diff --git a/test-playwright/40-add-contact.spec.ts b/test-playwright/40-add-contact.spec.ts
index bda4c41b..691ea7f2 100644
--- a/test-playwright/40-add-contact.spec.ts
+++ b/test-playwright/40-add-contact.spec.ts
@@ -158,7 +158,7 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Go to home view and look for gift
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
- const giftLink = page.locator('li:first-child').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
+ const giftLink = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
await expect(giftLink).toBeVisible();
await giftLink.click();
@@ -290,8 +290,8 @@ test('Copy contact to clipboard, then import ', async ({ page, context }, testIn
await page.locator('button', { hasText: 'Import' }).click();
await page.goto('./contacts');
- // Copy contact details
- await page.getByTestId('contactCheckAllTop').click();
+ // Select and copy exactly one contact (single-contact deep link flow)
+ await page.getByTestId('contactCheckOne').first().click();
const isChromium = await page.evaluate(() => {
return navigator.userAgent.includes('Chrome') || navigator.userAgent.includes('Chromium');
@@ -333,8 +333,89 @@ test('Copy contact to clipboard, then import ', async ({ page, context }, testIn
await expect(page.locator('div[role="alert"]')).toBeHidden({ timeout: 7000 });
await page.goto(clipboardText);
- // we're on the contact-import page
- await expect(page.getByRole('heading', { name: "Contact Import" })).toBeVisible();
- // For some reason, Chromium shows 1 contact the same but Firefox shows 4.
- await expect(page.locator('span', { hasText: 'the same as' })).toBeVisible();
+ // single-contact payload now auto-adds and routes to contact-edit
+ await expect(page).toHaveURL(/\/contact-edit\//);
+ await expect(page.getByTestId('contactName').locator('input')).toBeVisible();
+});
+
+test('Copied deep link with multiple contacts opens Contact Import', async ({ page, context }, testInfo) => {
+ await importUser(page, '00');
+
+ await page.goto('./contacts');
+
+ // Add contact #111
+ await page
+ .getByPlaceholder('URL or DID, Name, Public Key')
+ .fill('did:ethr:0x111d15564f824D56C7a07b913aA7aDd03382aA39, User #111');
+ await page.locator('button > svg.fa-plus').click();
+ await expect(page.getByRole('alert').filter({ hasText: 'Success' })).toBeVisible();
+ await page.locator('div[role="alert"] button > svg.fa-xmark').click();
+ await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'No', exact: true }).click();
+ await expect(page.getByRole('button', { name: 'No, Not Yet', exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'No, Not Yet', exact: true }).click();
+ await expect(page.locator('div[role="alert"]')).toHaveCount(0);
+
+ // Add contact #222
+ await page
+ .getByPlaceholder('URL or DID, Name, Public Key')
+ .fill('did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b, User #222');
+ await page.locator('button > svg.fa-plus').click();
+ await expect(page.getByRole('alert').filter({ hasText: 'Success' })).toBeVisible();
+ await page.locator('div[role="alert"] button > svg.fa-xmark').click();
+ await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'No', exact: true }).click();
+ await expect(page.getByRole('button', { name: 'No, Not Yet', exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'No, Not Yet', exact: true }).click();
+ await expect(page.locator('div[role="alert"]')).toHaveCount(0);
+
+ const isWebkit = await page.evaluate(() => {
+ return navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('iPhone');
+ });
+ if (isWebkit) {
+ console.log("Haven't found a way to access clipboard text in Webkit. Skipping.");
+ return;
+ }
+
+ const isChromium = await page.evaluate(() => {
+ return navigator.userAgent.includes('Chrome') || navigator.userAgent.includes('Chromium');
+ });
+ if (isChromium) {
+ await context.grantPermissions(['clipboard-read']);
+ }
+
+ await expect(page.getByTestId('contactListItem')).toHaveCount(2);
+ await page.getByTestId('contactCheckAllTop').click();
+ await page.getByTestId('copySelectedContactsButtonTop').click();
+ await page.waitForTimeout(100);
+
+ const clipboardText = await page.evaluate(async () => {
+ try {
+ return await navigator.clipboard.readText();
+ } catch (error) {
+ console.error('Clipboard read failed:', error);
+ return null;
+ }
+ });
+
+ const webServer = testInfo.config.webServer;
+ const clientServerUrl = webServer?.url;
+ const PATH_PART = clientServerUrl + '/deep-link/contact-import/';
+ await expect(clipboardText).toContain(PATH_PART);
+
+ // Delete one contact so import has at least one new contact
+ await page.getByTestId('contactListItem').nth(1).locator('h2 > a').click();
+ await expect(page.getByRole('heading', { name: 'Identifier Details' })).toBeVisible();
+ await page.locator('button > svg.fa-trash-can').click();
+ await page.locator('div[role="alert"] button:has-text("Yes")').click();
+ await expect(page.locator('div[role="alert"] button:has-text("Yes")')).toBeHidden();
+ await page.locator('div[role="alert"] button > svg.fa-xmark').click();
+ await page.goto('./contacts');
+ await expect(page.getByTestId('contactListItem')).toHaveCount(1);
+
+ await page.goto(clipboardText as string);
+ await expect(page.getByRole('heading', { name: 'Contact Import' })).toBeVisible();
+ await expect(page.locator('button', { hasText: 'Import Contacts' })).toBeVisible();
+ await page.locator('button', { hasText: 'Import Contacts' }).click();
+ await expect(page.getByTestId('contactListItem')).toHaveCount(2);
});
diff --git a/vite.config.common.mts b/vite.config.common.mts
index 59406781..51b7051a 100644
--- a/vite.config.common.mts
+++ b/vite.config.common.mts
@@ -28,6 +28,9 @@ export async function createBuildConfig(platform: string): Promise {
server: {
port: parseInt(process.env.VITE_PORT || "8080"),
fs: { strict: false },
+ //allowedHosts: ['bab3-68-69-173-46.ngrok-free.app'],
+ //allowedHosts: ['*'],
+
// CORS headers disabled to allow images from any domain
// This means SharedArrayBuffer is unavailable, but absurd-sql
// will automatically fall back to IndexedDB mode which still works