Merge branch 'master' into daily-notification-plugin-integration

This commit is contained in:
2026-03-07 10:48:07 -07:00
56 changed files with 3450 additions and 13063 deletions

View File

@@ -6,10 +6,13 @@ VITE_LOG_LEVEL=debug
# iOS doesn't like spaces in the app title.
TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
VITE_APP_SERVER=http://localhost:8080
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not
# This is the claim ID for actions in the BVC project, with the JWT ID on the environment
# test server
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
# production server
#VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
# Using shared server by default to ease setup, which works for shared test users.
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app

View File

@@ -6,11 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.6] - 2026
## [1.3.8] - 2026
### Added
- Device wake-up for notifications
## [1.3.7]
### Added
- Attendee exclusion and do-not-pair groups for meeting matching.
### Fixed
- Contact deep-links clicked or pasted act consistenly
## [1.3.5] - 2026.02.22
### Fixed
- SQL error on startup (contact_labels -> contacts foreign key)
### Added
- Ability to toggle embeddings on list of contacts
## [1.3.3] - 2026.02.17
### Added
- People can be marked as vector-embeddings users.
- People can be matched during a meeting.
### Fixed
- Problem hiding new contacts in feed
## [1.1.6] - 2026.01.21
### Added
- Labels on contacts
- Ability to switch giver & recipient on the gift-details page
### Changed
- Invitations now must be explicitly accepted.
### Fixed
- Show all starred projects.
- Incorrect contacts as "most recent" on gift-details page
## [1.1.5] - 2025.12.28
### Fixed
- Incorrect prompts in give-dialog on a project or offer

View File

@@ -27,7 +27,7 @@ Large Components (>500 lines): 5 components (12.5%)
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
└── MembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
└── MeetingMembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
Medium Components (200-500 lines): 12 components (30%)
├── GiftDetailsStep.vue (450 lines)

View File

@@ -15,7 +15,7 @@ Quick start:
```bash
npm install
npm run build:web:serve -- --test
npm run build:web:dev
```
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".

View File

@@ -38,7 +38,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 64
versionName "1.3.6"
versionName "1.3.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -534,7 +534,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.6;
MARKETING_VERSION = 1.3.8;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -562,7 +562,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.6;
MARKETING_VERSION = 1.3.8;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -594,7 +594,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.6;
MARKETING_VERSION = 1.3.8;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -632,7 +632,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.6;
MARKETING_VERSION = 1.3.8;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";

11806
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{
"name": "timesafari",
"version": "1.3.6",
"description": "Time Safari Application",
"version": "1.3.8-beta",
"description": "Gift Economies Application",
"author": {
"name": "Time Safari Team"
"name": "Gift Economies Team"
},
"scripts": {
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",

View File

@@ -116,7 +116,7 @@ echo "=============================="
# Analyze critical files identified in the assessment
critical_files=(
src/components/MembersList.vue"
src/components/MeetingMembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -93,7 +93,7 @@ echo "=========================="
# Critical files from our assessment
files=(
src/components/MembersList.vue"
src/components/MeetingMembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -93,7 +93,7 @@ echo "=========================="
# Critical files from our assessment
files=(
src/components/MembersList.vue"
src/components/MeetingMembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"

View File

@@ -21,7 +21,7 @@
</button>
<font-awesome
icon="circle-info"
class="text-2xl text-blue-500 ml-2"
class="text-2xl text-blue-500 ml-4"
@click="emitShowCopyInfo"
/>
</div>

View File

@@ -57,8 +57,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:selectable="youSelectable"
:conflicted="youConflicted"
:entity-data="youEntityData"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
@entity-selected="handleEntitySelected"
/>
@@ -69,8 +69,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:label="unnamedEntityName"
icon="circle-question"
:entity-data="unnamedEntityData"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
@entity-selected="handleEntitySelected"
/>
</template>
@@ -97,8 +97,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
@person-selected="handlePersonSelected"
/>
</template>
@@ -116,8 +116,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
@person-selected="handlePersonSelected"
/>
</template>
@@ -131,40 +131,40 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<template v-else-if="entityType === 'projects'">
<!-- When showing projects without search: split into recently bookmarked and rest -->
<!-- When showing projects without search: split into recently starred and rest -->
<template v-if="!searchTerm.trim()">
<!-- Recently Bookmarked Section -->
<template v-if="recentBookmarkedProjects.length > 0">
<!-- Recently Starred Section -->
<template v-if="recentStarredProjectsToShow.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Bookmarked
Recently Starred
</li>
<ProjectCard
v-for="project in recentBookmarkedProjects"
v-for="project in recentStarredProjectsToShow"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
@project-selected="handleProjectSelected"
/>
</template>
<!-- Rest of Projects Section -->
<li
v-if="recentBookmarkedProjects.length > 0"
v-if="remainingProjects.length > 0"
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
All Projects
@@ -177,8 +177,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
@project-selected="handleProjectSelected"
/>
</template>
@@ -193,8 +193,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
@project-selected="handleProjectSelected"
/>
</template>
@@ -223,7 +223,7 @@ import { TIMEOUTS } from "@/utils/notify";
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
const RECENT_BOOKMARKED_PROJECTS_COUNT = 10;
const RECENT_STARRED_PROJECTS_COUNT = 10;
/**
* EntityGrid - Unified grid layout for displaying people or projects
@@ -251,30 +251,6 @@ export default class EntityGrid extends Vue {
@Prop({ required: true })
entityType!: "people" | "projects";
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
// API server for project searches
apiServer = "";
// Internal project state (when entities prop not provided for projects)
allProjects: PlanData[] = [];
loadBeforeId: string | undefined = undefined;
isLoadingProjects = false;
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
// Starred projects state (for showing recently bookmarked projects)
starredPlanHandleIds: string[] = [];
/**
* Array of entities to display
*
@@ -326,32 +302,30 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
/**
* Function to determine which entities to display (allows parent control)
*
* This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering and sorting.
* Note: Infinite scroll is disabled when this prop is provided.
*
* @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects")
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
) => Contact[] | PlanData[];
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
// API server for project searches
apiServer = "";
// Internal project state (when entities prop not provided for projects)
allProjects: PlanData[] = [];
loadBeforeId: string | undefined = undefined;
isLoadingProjects = false;
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
// Starred projects state (for showing recently starred projects)
starredPlanHandleIds: string[] = [];
recentStarredProjects: PlanData[] = [];
/**
* CSS classes for the empty state message
@@ -397,11 +371,6 @@ export default class EntityGrid extends Vue {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
@@ -457,40 +426,19 @@ export default class EntityGrid extends Vue {
}
/**
* Get the 3 most recently bookmarked projects (when showing projects and not searching)
* The starredPlanHandleIds array order represents bookmark order (newest at the end)
* Get the 3 most recently starred projects (when showing projects and not searching)
* Returns the cached member field
*/
get recentBookmarkedProjects(): PlanData[] {
if (
this.entityType !== "projects" ||
this.searchTerm.trim() ||
this.starredPlanHandleIds.length === 0
) {
get recentStarredProjectsToShow(): PlanData[] {
if (this.entityType !== "projects" || this.searchTerm.trim()) {
return [];
}
const projects = this.entitiesToUse as PlanData[];
if (projects.length === 0) {
return [];
}
// Get the last 3 starred IDs (most recently bookmarked)
const recentStarredIds = this.starredPlanHandleIds.slice(
-RECENT_BOOKMARKED_PROJECTS_COUNT,
);
// Find projects matching those IDs, sorting with newest first
const recentProjects = recentStarredIds
.map((id) => projects.find((p) => p.handleId === id))
.filter((p): p is PlanData => p !== undefined)
.reverse();
return recentProjects;
return this.recentStarredProjects;
}
/**
* Get all projects (when showing projects and not searching)
* Includes projects shown in "Recently Bookmarked" section as well
* Includes projects shown in "Recently Starred" section as well
* Uses infinite scroll to control how many are displayed
*/
get remainingProjects(): PlanData[] {
@@ -552,6 +500,115 @@ export default class EntityGrid extends Vue {
return UNNAMED_ENTITY_NAME;
}
/**
* Initialize infinite scroll on mount
*/
async mounted(): Promise<void> {
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load starred project IDs for showing recently starred projects
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Load projects on mount if entities prop not provided
this.isLoadingProjects = true;
if (!this.entities) {
await this.loadProjects();
}
await this.loadRecentStarredProjects();
this.isLoadingProjects = false;
}
// Validate entities prop for people
if (this.entityType === "people") {
if (!this.entities) {
logger.error(
"EntityGrid: entities prop or allContacts prop is required when entityType is 'people'",
);
}
}
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
async () => {
// Search mode: handle search pagination
if (this.searchTerm.trim()) {
if (this.entityType === "projects") {
// Projects: load more search results if available
if (
this.displayedCount >= this.filteredEntities.length &&
this.searchBeforeId &&
!this.isLoadingSearchMore
) {
this.isLoadingSearchMore = true;
try {
const searchLower = this.searchTerm.toLowerCase().trim();
await this.loadProjects(this.searchBeforeId, searchLower);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more search results:", error);
// Error already handled in loadProjects
} finally {
this.isLoadingSearchMore = false;
}
} else {
// Show more from already-loaded search results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Contacts: show more from already-filtered results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Non-search mode
if (this.entityType === "projects") {
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// If using internal state and need to load more from server
if (
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
beforeId &&
!this.isLoadingProjects
) {
this.isLoadingProjects = true;
try {
await this.loadProjects(beforeId);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more projects:", error);
// Error already handled in loadProjects
} finally {
this.isLoadingProjects = false;
}
} else {
// Normal case: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
} else {
// People: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
}
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
/**
* Check if a person DID is conflicted
*/
@@ -636,7 +693,7 @@ export default class EntityGrid extends Vue {
if (this.entityType === "projects") {
// Server-side search for projects (initial load, no beforeId)
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(undefined, searchLower);
await this.loadProjects(undefined, searchLower);
} else {
// Client-side filtering for contacts (complete list)
await this.performContactSearch();
@@ -659,10 +716,7 @@ export default class EntityGrid extends Vue {
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
*/
async fetchProjects(
beforeId?: string,
claimContents?: string,
): Promise<void> {
async loadProjects(beforeId?: string, claimContents?: string): Promise<void> {
if (!this.apiServer) {
if (claimContents) {
this.filteredEntities = [];
@@ -806,6 +860,57 @@ export default class EntityGrid extends Vue {
}
}
/**
* Load the most recently starred projects
* The starredPlanHandleIds array order represents starred order (newest at the end)
*/
async loadRecentStarredProjects(): Promise<void> {
if (
this.entityType !== "projects" ||
this.searchTerm.trim() ||
this.starredPlanHandleIds.length === 0
) {
this.recentStarredProjects = [];
return;
}
// Get the last 3 starred IDs (most recently starred)
const recentStarredIds = this.starredPlanHandleIds.slice(
-RECENT_STARRED_PROJECTS_COUNT,
);
// Find projects matching those IDs, sorting with newest first
const projects = this.entitiesToUse as PlanData[];
const recentProjects = recentStarredIds
.map((id) => projects.find((p) => p.handleId === id))
.filter((p): p is PlanData => p !== undefined)
.reverse();
// If any projects are not found, fetch them from the API server
if (recentProjects.length < recentStarredIds.length) {
const missingIds = recentStarredIds.filter(
(id) => !recentProjects.some((p) => p.handleId === id),
);
const missingProjects = await this.fetchProjectsByIds(missingIds);
recentProjects.push(...missingProjects);
}
this.recentStarredProjects = recentProjects;
}
async fetchProjectsByIds(ids: string[]): Promise<PlanData[]> {
const idsString = encodeURIComponent(JSON.stringify(ids));
const url = `${this.apiServer}/api/v2/report/plans?planHandleIds=${idsString}`;
const response = await fetch(url, {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error("Failed to fetch projects");
}
const results = await response.json();
return results.data;
}
/**
* Client-side contact search
* Assumes entities prop contains complete contact list from local database
@@ -860,11 +965,6 @@ export default class EntityGrid extends Vue {
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// Search mode: check if more results available
if (this.entityType === "projects") {
@@ -911,129 +1011,6 @@ export default class EntityGrid extends Vue {
return this.displayedCount < this.entities.length;
}
/**
* Initialize infinite scroll on mount
*/
async mounted(): Promise<void> {
// Load apiServer for project searches/loads
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load starred project IDs for showing recently bookmarked projects
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Load projects on mount if entities prop not provided
if (!this.entities && this.apiServer) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error("Error loading projects on mount:", error);
} finally {
this.isLoadingProjects = false;
}
}
}
// Validate entities prop for people
if (this.entityType === "people" && !this.entities) {
logger.error(
"EntityGrid: entities prop is required when entityType is 'people'",
);
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Contacts data is required but not provided.",
},
TIMEOUTS.SHORT,
);
}
}
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
async () => {
// Search mode: handle search pagination
if (this.searchTerm.trim()) {
if (this.entityType === "projects") {
// Projects: load more search results if available
if (
this.displayedCount >= this.filteredEntities.length &&
this.searchBeforeId &&
!this.isLoadingSearchMore
) {
this.isLoadingSearchMore = true;
try {
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(this.searchBeforeId, searchLower);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more search results:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingSearchMore = false;
}
} else {
// Show more from already-loaded search results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Contacts: show more from already-filtered results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Non-search mode
if (this.entityType === "projects") {
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// If using internal state and need to load more from server
if (
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
beforeId &&
!this.isLoadingProjects
) {
this.isLoadingProjects = true;
try {
await this.fetchProjects(beforeId);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more projects:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingProjects = false;
}
} else {
// Normal case: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
} else {
// People: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
}
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
@@ -1061,28 +1038,17 @@ export default class EntityGrid extends Vue {
// When switching to projects, load them if not provided via entities prop
if (newType === "projects" && !this.entities) {
// Ensure apiServer is loaded
if (!this.apiServer) {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
}
// Load projects if we have an API server
if (this.apiServer && this.allProjects.length === 0) {
if (this.allProjects.length === 0) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error(
"Error loading projects when switching to projects:",
error,
);
} finally {
await this.loadProjects();
await this.loadRecentStarredProjects();
this.isLoadingProjects = false;
}
}
}
// Clear project state when switching away from projects
if (newType === "people") {

View File

@@ -24,14 +24,14 @@ properties * * @author Matthew Raymer */
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects || undefined : allContacts"
:entities="shouldShowProjects ? undefined : allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:you-selectable="youSelectable"
:notify="notify"
:conflict-context="conflictContext"
:notify="notify"
:you-selectable="youSelectable"
@entity-selected="handleEntitySelected"
/>
@@ -45,16 +45,7 @@ import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* Entity data interface for giver/receiver
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
import { GiverReceiverInputInfo } from "@/libs/util";
/**
* Entity selection event data structure
@@ -87,10 +78,6 @@ export default class EntitySelectionStep extends Vue {
@Prop({ required: true })
stepType!: "giver" | "recipient";
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
@Prop({ required: false })
projects?: PlanData[];
/** Array of available contacts */
@Prop({ required: true })
allContacts!: Contact[];
@@ -107,35 +94,13 @@ export default class EntitySelectionStep extends Vue {
@Prop({ required: true })
conflictChecker!: (did: string) => boolean;
/** Project ID for context (giver) */
@Prop({ default: "" })
fromProjectId!: string;
/** Project ID for context (recipient) */
@Prop({ default: "" })
toProjectId!: string;
/** Current giver entity for context */
@Prop()
giver?: EntityData | null;
giver?: GiverReceiverInputInfo | null;
/** Current receiver entity for context */
@Prop()
receiver?: EntityData | null;
/** Form field values to preserve when navigating to "Show All" */
@Prop({ default: "" })
description!: string;
@Prop({ default: "0" })
amountInput!: string;
@Prop({ default: "HUR" })
unitCode!: string;
/** Offer ID for context when fulfilling an offer */
@Prop({ default: "" })
offerId!: string;
receiver?: GiverReceiverInputInfo | null;
/** Notification function from parent component */
@Prop()

View File

@@ -50,16 +50,7 @@ import EntityIcon from "./EntityIcon.vue";
import ProjectIcon from "./ProjectIcon.vue";
import { Contact } from "../db/tables/contacts";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* Entity interface for both person and project entities
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
import { GiverReceiverInputInfo } from "@/libs/util";
/**
* EntitySummaryButton - Displays selected entity with edit capability
@@ -81,11 +72,7 @@ interface EntityData {
export default class EntitySummaryButton extends Vue {
/** Entity data to display */
@Prop({ required: true })
entity!: EntityData | Contact | null;
/** Type of entity: 'person' or 'project' */
@Prop({ required: true })
entityType!: "person" | "project";
entity!: GiverReceiverInputInfo | Contact | null;
/** Display label for the entity role */
@Prop({ required: true })
@@ -98,9 +85,13 @@ export default class EntitySummaryButton extends Vue {
@Prop({ type: Function, default: () => {} })
onEditRequested!: (data: {
entityType: string;
entity: EntityData | Contact | null;
entity: GiverReceiverInputInfo | Contact | null;
}) => void | Promise<void>;
get entityType(): string {
return this.entity && "handleId" in this.entity ? "project" : "person";
}
/**
* CSS classes for the main container
*/

View File

@@ -14,7 +14,6 @@ control over updates and validation * * @author Matthew Raymer */
<!-- Giver Button -->
<EntitySummaryButton
:entity="giver"
:entity-type="giverEntityType"
:label="giverLabel"
:on-edit-requested="handleEditGiver"
/>
@@ -22,7 +21,6 @@ control over updates and validation * * @author Matthew Raymer */
<!-- Recipient Button -->
<EntitySummaryButton
:entity="receiver"
:entity-type="recipientEntityType"
:label="recipientLabel"
:on-edit-requested="handleEditRecipient"
/>
@@ -104,16 +102,7 @@ import { Component, Prop, Vue, Watch, Emit } from "vue-facing-decorator";
import EntitySummaryButton from "./EntitySummaryButton.vue";
import AmountInput from "./AmountInput.vue";
import { RouteLocationRaw } from "vue-router";
/**
* Entity data interface for giver/receiver
*/
interface EntityData {
did?: string;
handleId?: string;
name?: string;
image?: string;
}
import { GiverReceiverInputInfo } from "@/libs/util";
/**
* GiftDetailsStep - Complete step 2 gift details form interface
@@ -139,19 +128,11 @@ interface EntityData {
export default class GiftDetailsStep extends Vue {
/** Giver entity data */
@Prop({ required: true })
giver!: EntityData | null;
giver!: GiverReceiverInputInfo | null;
/** Receiver entity data */
@Prop({ required: true })
receiver!: EntityData | null;
/** Type of giver entity: 'person' or 'project' */
@Prop({ required: true })
giverEntityType!: "person" | "project";
/** Type of recipient entity: 'person' or 'project' */
@Prop({ required: true })
recipientEntityType!: "person" | "project";
receiver!: GiverReceiverInputInfo | null;
/** Gift description */
@Prop({ default: "" })
@@ -211,6 +192,14 @@ export default class GiftDetailsStep extends Vue {
private localAmount: number = 0;
private localUnitCode: string = "HUR";
get giverEntityType(): string {
return this.giver?.handleId ? "project" : "person";
}
get recipientEntityType(): string {
return this.receiver?.handleId ? "project" : "person";
}
/**
* CSS classes for the photo & more options link
*/
@@ -332,7 +321,7 @@ export default class GiftDetailsStep extends Vue {
*/
handleEditGiver(_data: {
entityType: string;
entity: EntityData | null;
entity: GiverReceiverInputInfo | null;
}): void {
this.emitEditEntity({
entityType: "giver",
@@ -346,7 +335,7 @@ export default class GiftDetailsStep extends Vue {
*/
handleEditRecipient(_data: {
entityType: string;
entity: EntityData | null;
entity: GiverReceiverInputInfo | null;
}): void {
this.emitEditEntity({
entityType: "recipient",
@@ -386,8 +375,8 @@ export default class GiftDetailsStep extends Vue {
@Emit("edit-entity")
emitEditEntity(data: {
entityType: string;
currentEntity: EntityData | null;
}): { entityType: string; currentEntity: EntityData | null } {
currentEntity: GiverReceiverInputInfo | null;
}): { entityType: string; currentEntity: GiverReceiverInputInfo | null } {
return data;
}

View File

@@ -3,30 +3,18 @@
<div
class="dialog"
data-testid="gifted-dialog"
:data-recipient-entity-type="currentRecipientEntityType"
:data-recipient-entity-type="recipientEntityType"
>
<!-- Step 1: Entity Selection -->
<EntitySelectionStep
v-show="firstStep"
:step-type="stepType"
:giver-entity-type="currentGiverEntityType"
:recipient-entity-type="currentRecipientEntityType"
:show-projects="
currentGiverEntityType === 'project' ||
currentRecipientEntityType === 'project'
"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:conflict-checker="wouldCreateConflict"
:from-project-id="fromProjectId"
:to-project-id="toProjectId"
:giver="giver"
:receiver="receiver"
:description="description"
:amount-input="amountInput"
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
@entity-selected="handleEntitySelected"
@cancel="cancel"
@@ -37,8 +25,8 @@
v-show="!firstStep"
:giver="giver"
:receiver="receiver"
:giver-entity-type="currentGiverEntityType"
:recipient-entity-type="currentRecipientEntityType"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:description="description"
:amount="parseFloat(amountInput) || 0"
:unit-code="unitCode"
@@ -129,8 +117,6 @@ export default class GiftedDialog extends Vue {
description = "";
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
currentGiverEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
currentRecipientEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
offerId = "";
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
@@ -142,12 +128,20 @@ export default class GiftedDialog extends Vue {
didInfo = didInfo;
get giverEntityType(): string {
return this.giver && "handleId" in this.giver ? "project" : "person";
}
get recipientEntityType(): string {
return this.receiver && "handleId" in this.receiver ? "project" : "person";
}
// Computed property to check if current selection would create a conflict
get hasPersonConflict() {
// Only check for conflicts when both entities are persons
if (
this.currentGiverEntityType !== "person" ||
this.currentRecipientEntityType !== "person"
this.giverEntityType !== "person" ||
this.recipientEntityType !== "person"
) {
return false;
}
@@ -168,8 +162,8 @@ export default class GiftedDialog extends Vue {
get hasProjectConflict() {
// Only check for conflicts when both entities are projects
if (
this.currentGiverEntityType !== "project" ||
this.currentRecipientEntityType !== "project"
this.giverEntityType !== "project" ||
this.recipientEntityType !== "project"
) {
return false;
}
@@ -204,9 +198,6 @@ export default class GiftedDialog extends Vue {
this.amountInput = amountInput || "0";
this.unitCode = unitCode || "HUR";
this.callbackOnSuccess = callbackOnSuccess;
// Initialize current entity types from initial prop values
this.currentGiverEntityType = this.initialGiverEntityType;
this.currentRecipientEntityType = this.initialRecipientEntityType;
try {
const settings = await this.$accountSettings();
@@ -277,8 +268,8 @@ export default class GiftedDialog extends Vue {
wouldCreateConflict(identifier: string) {
// Check for person conflicts when both entities are persons
if (
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "person"
this.giverEntityType === "person" &&
this.recipientEntityType === "person"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
@@ -291,8 +282,8 @@ export default class GiftedDialog extends Vue {
// Check for project conflicts when both entities are projects
if (
this.currentGiverEntityType === "project" &&
this.currentRecipientEntityType === "project"
this.giverEntityType === "project" &&
this.recipientEntityType === "project"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
@@ -314,9 +305,6 @@ export default class GiftedDialog extends Vue {
this.prompt = "";
this.unitCode = "HUR";
this.firstStep = true;
// Reset to initial prop values
this.currentGiverEntityType = this.initialGiverEntityType;
this.currentRecipientEntityType = this.initialRecipientEntityType;
}
async confirm() {
@@ -404,8 +392,8 @@ export default class GiftedDialog extends Vue {
let providerPlanHandleId: string | undefined;
if (
this.currentGiverEntityType === "project" &&
this.currentRecipientEntityType === "person"
this.giverEntityType === "project" &&
this.recipientEntityType === "person"
) {
// Project-to-person gift
fromDid = undefined;
@@ -413,8 +401,8 @@ export default class GiftedDialog extends Vue {
fulfillsProjectHandleId = undefined;
providerPlanHandleId = this.giver?.handleId;
} else if (
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "project"
this.giverEntityType === "person" &&
this.recipientEntityType === "project"
) {
// Person-to-project gift
fromDid = giverDid as string;
@@ -422,8 +410,8 @@ export default class GiftedDialog extends Vue {
fulfillsProjectHandleId = this.receiver?.handleId;
providerPlanHandleId = undefined;
} else if (
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "person"
this.giverEntityType === "person" &&
this.recipientEntityType === "person"
) {
// Person-to-person gift
fromDid = giverDid as string;

View File

@@ -0,0 +1,279 @@
<template>
<div>
<div v-for="group in groups" :key="group.id" class="mb-3">
<div
:class="[
'rounded-lg border p-3',
colorSet(group.colorIndex).bg,
colorSet(group.colorIndex).border,
]"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 flex-1 min-w-0">
<span
:class="[
'w-3 h-3 rounded-full shrink-0',
colorSet(group.colorIndex).dot,
]"
></span>
<input
:value="group.name"
:disabled="disabled"
:class="[
'text-sm font-medium bg-transparent border-none',
'outline-none flex-1 min-w-0 placeholder-gray-400',
{ 'cursor-default': disabled },
]"
placeholder="Group name…"
@input="
updateGroupName(
group.id,
($event.target as HTMLInputElement).value,
)
"
/>
</div>
<button
:class="[
'transition-colors ml-2 shrink-0',
disabled
? 'text-slate-300 cursor-not-allowed'
: 'text-slate-400 hover:text-red-600',
]"
title="Delete group"
@click="disabled ? notifyLocked() : removeGroup(group.id)"
>
<font-awesome icon="trash-can" class="text-sm" />
</button>
</div>
<div class="flex flex-wrap gap-1.5 mb-2">
<span
v-for="did in group.memberDids"
:key="did"
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs',
colorSet(group.colorIndex).chip,
]"
>
{{ getMemberName(did) }}
<button
:class="
disabled ? 'opacity-40 cursor-not-allowed' : 'hover:opacity-70'
"
@click="
disabled ? notifyLocked() : removeMemberFromGroup(group.id, did)
"
>
<font-awesome icon="xmark" class="text-xs" />
</button>
</span>
<span
v-if="group.memberDids.length === 0"
class="text-xs text-slate-400 italic"
>
No members yet
</span>
</div>
<div v-if="!disabled && addingToGroupId === group.id" class="mt-2">
<div
class="flex flex-wrap gap-1.5 p-2 bg-white bg-opacity-60 rounded border border-gray-200"
>
<button
v-for="member in availableMembersForGroup(group)"
:key="member.did"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-white border border-gray-300 hover:bg-gray-100 transition-colors"
@click="addMemberToGroup(group.id, member.did)"
>
<font-awesome icon="plus" class="text-xs text-green-600" />
{{ member.name }}
</button>
<span
v-if="availableMembersForGroup(group).length === 0"
class="text-xs text-slate-400 italic"
>
All members already assigned
</span>
</div>
<button
class="text-xs text-slate-500 mt-1"
@click="addingToGroupId = ''"
>
Done
</button>
</div>
<button
v-else
:class="[
'text-xs transition-colors',
disabled
? 'text-slate-400 cursor-not-allowed'
: 'text-blue-600 hover:text-blue-800',
]"
@click="disabled ? notifyLocked() : (addingToGroupId = group.id)"
>
<font-awesome icon="plus" class="text-xs" />
Add member
</button>
</div>
</div>
<button
:class="[
'text-sm transition-colors',
disabled
? 'text-slate-400 cursor-not-allowed'
: 'text-blue-600 hover:text-blue-800',
]"
@click="disabled ? notifyLocked() : addGroup()"
>
<font-awesome icon="plus" class="text-sm" />
New Group
</button>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
import { DoNotPairGroup } from "@/interfaces";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NotificationIface } from "@/constants/app";
interface MemberInfo {
did: string;
name: string;
}
const GROUP_COLORS = [
{
bg: "bg-orange-50",
border: "border-orange-200",
dot: "bg-orange-400",
chip: "bg-orange-200 text-orange-800",
},
{
bg: "bg-purple-50",
border: "border-purple-200",
dot: "bg-purple-400",
chip: "bg-purple-200 text-purple-800",
},
{
bg: "bg-teal-50",
border: "border-teal-200",
dot: "bg-teal-400",
chip: "bg-teal-200 text-teal-800",
},
{
bg: "bg-pink-50",
border: "border-pink-200",
dot: "bg-pink-400",
chip: "bg-pink-200 text-pink-800",
},
{
bg: "bg-indigo-50",
border: "border-indigo-200",
dot: "bg-indigo-400",
chip: "bg-indigo-200 text-indigo-800",
},
{
bg: "bg-yellow-50",
border: "border-yellow-200",
dot: "bg-yellow-400",
chip: "bg-yellow-200 text-yellow-800",
},
];
@Component
export default class MeetingExclusionGroups extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
notify!: ReturnType<typeof createNotifyHelpers>;
@Prop({ required: true }) groups!: DoNotPairGroup[];
@Prop({ required: true }) availableMembers!: MemberInfo[];
@Prop({ default: false }) disabled!: boolean;
addingToGroupId = "";
created() {
this.notify = createNotifyHelpers(this.$notify);
}
notifyLocked(): void {
this.notify.warning(
"Erase the current matches before changing exclusion groups.",
TIMEOUTS.LONG,
);
}
colorSet(colorIndex: number): (typeof GROUP_COLORS)[0] {
return GROUP_COLORS[colorIndex % GROUP_COLORS.length];
}
getMemberName(did: string): string {
const member = this.availableMembers.find((m) => m.did === did);
return member?.name || did.substring(0, 16) + "…";
}
availableMembersForGroup(group: DoNotPairGroup): MemberInfo[] {
const allAssignedDids = new Set(this.groups.flatMap((g) => g.memberDids));
return this.availableMembers
.filter(
(m) => !allAssignedDids.has(m.did) || group.memberDids.includes(m.did),
)
.filter((m) => !group.memberDids.includes(m.did));
}
@Emit("update")
emitUpdate(): DoNotPairGroup[] {
return [...this.groups];
}
addGroup(): void {
const newGroup: DoNotPairGroup = {
id: Date.now().toString(36) + Math.random().toString(36).substring(2, 6),
name: "",
colorIndex: this.groups.length % GROUP_COLORS.length,
memberDids: [],
};
this.groups.push(newGroup);
this.addingToGroupId = newGroup.id;
this.emitUpdate();
}
removeGroup(groupId: string): void {
const idx = this.groups.findIndex((g) => g.id === groupId);
if (idx !== -1) {
this.groups.splice(idx, 1);
if (this.addingToGroupId === groupId) {
this.addingToGroupId = "";
}
this.emitUpdate();
}
}
updateGroupName(groupId: string, name: string): void {
const group = this.groups.find((g) => g.id === groupId);
if (group) {
group.name = name;
this.emitUpdate();
}
}
addMemberToGroup(groupId: string, did: string): void {
const group = this.groups.find((g) => g.id === groupId);
if (group && !group.memberDids.includes(did)) {
group.memberDids.push(did);
this.emitUpdate();
}
}
removeMemberFromGroup(groupId: string, did: string): void {
const group = this.groups.find((g) => g.id === groupId);
if (group) {
group.memberDids = group.memberDids.filter((d) => d !== did);
this.emitUpdate();
}
}
}
</script>

View File

@@ -0,0 +1,249 @@
<template>
<div class="group-onboard-match-display">
<!-- Loading -->
<div
v-if="isLoading"
class="flex items-center justify-center gap-2 py-6 text-slate-600"
>
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<!-- Error -->
<div
v-else-if="errorMessage"
class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700"
>
Inform the organizer that there was an error. {{ errorMessage }}
</div>
<!-- Matched person -->
<div
v-else-if="matchedPerson"
class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm"
>
<h2 class="mb-3 font-bold text-slate-700">Your Current Match</h2>
<p v-if="myPair != null" class="mb-3 text-sm text-slate-600">
You are in Pair #{{ myPair.pairNumber }} with:
</p>
<div class="flex items-start gap-3">
<EntityIcon
:contact="matchedPersonContact"
class="!size-14 shrink-0 overflow-hidden rounded-full border border-slate-300 bg-white"
/>
<div class="min-w-0 flex-1">
<p class="font-medium text-slate-900">
{{ matchedPerson.name || "(No name)" }}
</p>
<p class="mt-0.5 truncate text-xs text-slate-500">
{{ matchedPerson.did }}
</p>
<p
v-if="matchedPerson.description"
class="mt-2 line-clamp-3 text-sm text-slate-600"
>
{{ matchedPerson.description }}
</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from "vue-facing-decorator";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { decryptMessage } from "@/libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import EntityIcon from "./EntityIcon.vue";
import { Contact } from "@/db/tables/contacts";
import { AxiosErrorResponse } from "@/interfaces";
/** Participant from GET /api/partner/groupOnboardMatch pair */
interface MatchPairParticipant {
issuerDid: string;
content: string;
description?: string;
decryptedContentObject?: {
name: string;
did: string;
isRegistered: boolean;
} | null;
}
interface MatchPair {
pairNumber: number;
similarity: number;
participants: MatchPairParticipant[];
}
/** Normalized matched person for display */
interface MatchedPersonData {
name: string;
did: string;
isRegistered: boolean;
description?: string;
}
@Component({
components: {
EntityIcon,
},
mixins: [PlatformServiceMixin],
})
export default class GroupOnboardMatchDisplay extends Vue {
@Prop({ required: true })
meetingPassword!: string;
/** When provided, used to determine this person's match instead of calling groupOnboardMatch */
@Prop()
matchPairs?: MatchPair[] | null;
activeDid = "";
apiServer = "";
errorMessage = "";
isLoading = true;
matchedPerson: MatchedPersonData | null = null;
/** Pair that contains the current user (for similarity display if needed) */
myPair: MatchPair | null = null;
/** Contact-like object for EntityIcon from matched person */
get matchedPersonContact(): Contact | undefined {
if (!this.matchedPerson) return undefined;
return {
did: this.matchedPerson.did,
name: this.matchedPerson.name,
};
}
async created() {
const settings = await this.$accountSettings();
this.apiServer = settings?.apiServer || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity?.activeDid || "";
await this.fetchMatches();
}
@Watch("meetingPassword")
async onPasswordChange() {
if (
this.meetingPassword &&
(this.matchPairs != null || (this.apiServer && this.activeDid))
) {
await this.fetchMatches();
}
}
@Watch("matchPairs")
async onMatchPairsChange() {
if (this.activeDid && this.meetingPassword) {
await this.fetchMatches();
}
}
// Note that this is called externally by MeetingMembersList when user triggers a refresh
async fetchMatches(): Promise<void> {
const usePropPairs =
this.matchPairs != null &&
Array.isArray(this.matchPairs) &&
this.matchPairs.length > 0;
const needApi = !usePropPairs;
if (
needApi &&
(!this.meetingPassword?.trim() || !this.apiServer || !this.activeDid)
) {
this.isLoading = false;
this.matchedPerson = null;
this.myPair = null;
this.errorMessage = "";
return;
}
if (usePropPairs && (!this.meetingPassword?.trim() || !this.activeDid)) {
this.isLoading = false;
this.matchedPerson = null;
this.myPair = null;
this.errorMessage = "";
return;
}
this.isLoading = true;
this.errorMessage = "";
this.matchedPerson = null;
this.myPair = null;
try {
let pairs: MatchPair[] | null = null;
if (usePropPairs) {
// Shallow-copy so we can set decryptedContentObject without mutating the prop
pairs = (this.matchPairs ?? []).map((p) => ({
...p,
participants: p.participants.map((part) => ({ ...part })),
}));
} else {
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMatch`,
{ headers },
);
pairs = response?.data?.data?.pairs ?? null;
}
if (!Array.isArray(pairs) || pairs.length === 0) {
this.isLoading = false;
return;
}
// Decrypt each participant's content and find the pair containing this user
for (const pair of pairs) {
if (!pair.participants || pair.participants.length !== 2) continue;
for (const participant of pair.participants) {
try {
const decrypted = await decryptMessage(
participant.content,
this.meetingPassword,
);
participant.decryptedContentObject = JSON.parse(decrypted);
} catch {
participant.decryptedContentObject = null;
}
}
const myIndex = pair.participants.findIndex(
(p) => p.issuerDid === this.activeDid,
);
if (myIndex === -1) continue;
this.myPair = pair;
const other = pair.participants[1 - myIndex];
const obj = other.decryptedContentObject;
this.matchedPerson = {
name: obj?.name ?? "",
did: obj?.did ?? other.issuerDid,
isRegistered: !!obj?.isRegistered,
description: other.description,
};
break;
}
this.isLoading = false;
} catch (error) {
this.$logAndConsole(
"Error fetching group onboard match: " + errorStringForLog(error),
true,
);
this.errorMessage =
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
"Failed to load your match.";
this.matchedPerson = null;
this.myPair = null;
this.isLoading = false;
}
}
}
</script>

View File

@@ -36,6 +36,15 @@
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
to add/remove them to/from the meeting.
</li>
<li
v-if="
membersToShow().length > 0 && showOrganizerTools && isOrganizer
"
>
Click
<font-awesome icon="ban" class="text-slate-500 text-sm" />
to exclude someone from matching.
</li>
<li
v-if="
membersToShow().length > 0 && getNonContactMembers().length > 0
@@ -47,7 +56,14 @@
</li>
</ul>
<div class="flex justify-between">
<MeetingMemberMatch
ref="memberMatch"
:match-pairs="matchPairs"
:meeting-password="password || ''"
class="mt-4"
/>
<div class="flex justify-between mt-4">
<!--
always have at least one refresh button even without members in case the organizer
changes the password
@@ -75,6 +91,8 @@
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
!member.member.admitted &&
(isOrganizer || member.did === activeDid),
'bg-amber-50 opacity-60':
member.member.admitted && excludedDids.includes(member.did),
},
{ 'border-slate-300': member.member.admitted },
]"
@@ -88,6 +106,9 @@
'text-slate-500':
!member.member.admitted &&
(isOrganizer || member.did === activeDid),
'line-through text-slate-400':
member.member.admitted &&
excludedDids.includes(member.did),
},
]"
>
@@ -161,8 +182,31 @@
v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid
"
class="flex items-center gap-1.5"
class="flex items-center gap-6"
>
<button
v-if="member.member.admitted"
:class="[
'btn-exclusion-toggle',
exclusionLocked
? excludedDids.includes(member.did)
? 'text-amber-400 opacity-50'
: 'text-slate-300 opacity-50'
: excludedDids.includes(member.did)
? 'text-amber-600'
: 'text-slate-500',
]"
:title="
exclusionLocked
? 'Erase matches to change exclusions'
: excludedDids.includes(member.did)
? 'Include in matching'
: 'Exclude from matching'
"
@click="handleExclusionClick(member.did)"
>
<font-awesome icon="ban" />
</button>
<button
:class="
member.member.admitted
@@ -246,10 +290,13 @@ import {
} from "@/libs/endorserServer";
import { decryptMessage } from "@/libs/crypto";
import { Contact } from "@/db/tables/contacts";
import { MemberData } from "@/interfaces";
import { MemberData, MatchPair } from "@/interfaces";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import BulkMembersDialog from "./BulkMembersDialog.vue";
import MeetingMemberMatch from "./MeetingMemberMatch.vue";
const AUTO_REFRESH_INTERVAL = 15;
interface Member {
admitted: boolean;
@@ -257,6 +304,7 @@ interface Member {
memberId: number;
}
// there's a similar structure in OnboardMeetingSetupView.vue but without the member
interface DecryptedMember {
member: Member;
name: string;
@@ -267,16 +315,20 @@ interface DecryptedMember {
@Component({
components: {
BulkMembersDialog,
MeetingMemberMatch,
},
mixins: [PlatformServiceMixin],
})
export default class MembersList extends Vue {
export default class MeetingMembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
notify!: ReturnType<typeof createNotifyHelpers>;
@Prop({ required: true }) password!: string;
@Prop({ default: false }) showOrganizerTools!: boolean;
@Prop({ default: null }) matchPairs!: MatchPair[] | null;
@Prop({ default: () => [] }) excludedDids!: string[];
@Prop({ default: false }) exclusionLocked!: boolean;
// Emit methods using @Emit decorator
@Emit("error")
@@ -284,6 +336,16 @@ export default class MembersList extends Vue {
return message;
}
@Emit("toggle-exclusion")
emitToggleExclusion(did: string) {
return did;
}
@Emit("members-loaded")
emitMembersLoaded() {
return;
}
contacts: Array<Contact> = [];
decryptedMembers: DecryptedMember[] = [];
firstName = "";
@@ -296,7 +358,7 @@ export default class MembersList extends Vue {
apiServer = "";
// Auto-refresh functionality
countdownTimer = 10;
countdownTimer = AUTO_REFRESH_INTERVAL;
autoRefreshInterval: NodeJS.Timeout | null = null;
lastRefreshTime = 0;
previousMemberDidsIgnored: string[] = [];
@@ -345,6 +407,7 @@ export default class MembersList extends Vue {
this.emitError(serverMessageForUser(error) || "Failed to fetch members.");
} finally {
this.isLoading = false;
this.emitMembersLoaded();
}
}
@@ -486,7 +549,7 @@ export default class MembersList extends Vue {
informAboutAdmission() {
this.notify.info(
"This is to register people in Time Safari and to admit them to the meeting. A (+) symbol means they are not yet admitted and you can register and admit them. A (-) symbol means you can remove them, but they will stay registered.",
"Click the 'ban' button to exclude from matching. The '+/-' buttons are for admissions: A blue (+) symbol means they are not yet admitted and you can register and admit them. A red (-) symbol means you can remove them, but they will stay registered.",
TIMEOUTS.VERY_LONG,
);
}
@@ -544,8 +607,17 @@ export default class MembersList extends Vue {
* (admit pending members for organizers, add to contacts for non-organizers)
*/
async refreshData(bypassPromptIfAllWereIgnored = true) {
// Force refresh both contacts and members
// Force refresh of many things
// Matches may have been generated or erased
(
this.$refs.memberMatch as InstanceType<typeof MeetingMemberMatch>
)?.fetchMatches();
// Someone may have been added to their contacts
this.contacts = await this.$getAllContacts();
// The members list may have changed
await this.fetchMembers();
const pendingMembers = this.isOrganizer
@@ -724,25 +796,42 @@ export default class MembersList extends Vue {
}
}
getAdmittedMembers(): Array<{ did: string; name: string }> {
return this.decryptedMembers
.filter((m) => m.member.admitted)
.map((m) => ({ did: m.did, name: m.name }));
}
handleExclusionClick(did: string): void {
if (this.exclusionLocked) {
this.notify.warning(
"Erase the current matches before changing exclusions.",
TIMEOUTS.LONG,
);
return;
}
this.emitToggleExclusion(did);
}
startAutoRefresh() {
this.stopAutoRefresh();
this.lastRefreshTime = Date.now();
this.countdownTimer = 10;
this.countdownTimer = AUTO_REFRESH_INTERVAL;
this.autoRefreshInterval = setInterval(() => {
const now = Date.now();
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
if (timeSinceLastRefresh >= 10) {
if (timeSinceLastRefresh >= AUTO_REFRESH_INTERVAL) {
// Time to refresh
this.refreshData();
this.lastRefreshTime = now;
this.countdownTimer = 10;
this.countdownTimer = AUTO_REFRESH_INTERVAL;
} else {
// Update countdown
this.countdownTimer = Math.max(
0,
Math.round(10 - timeSinceLastRefresh),
Math.round(AUTO_REFRESH_INTERVAL - timeSinceLastRefresh),
);
}
}, 1000); // Update every second
@@ -789,6 +878,11 @@ export default class MembersList extends Vue {
transition-colors;
}
.btn-exclusion-toggle {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply text-lg transition-colors;
}
.btn-admission-remove {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply text-lg text-rose-500 hover:text-rose-700

View File

@@ -24,7 +24,7 @@
<div v-if="visible" class="dialog-overlay">
<div v-if="page === OnboardPage.Home" class="dialog">
<h1 class="text-xl font-bold text-center mb-4 relative">
Welcome to Time Safari
Welcome to {{ AppString.APP_NAME }}
<br />
- Showcase Impact & Magnify Time
<div :class="closeButtonClasses" @click="onClickClose(true)">
@@ -199,7 +199,7 @@
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "../constants/app";
import { AppString, NotificationIface } from "../constants/app";
import { OnboardPage } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -226,6 +226,13 @@ export default class OnboardingDialog extends Vue {
return OnboardPage;
}
/**
* Returns AppString enum for template access
*/
get AppString() {
return AppString;
}
/**
* CSS classes for primary action buttons (blue gradient)
*/

View File

@@ -6,8 +6,8 @@
export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari",
APP_NAME_NO_SPACES = "TimeSafari",
APP_NAME = "Gift Economies",
APP_NAME_NO_SPACES = "GiftEconomies",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
@@ -56,6 +56,7 @@ export const IMAGE_TYPE_PROFILE = "profile";
export function isNotProdServer(apiServer: string): boolean {
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
}
export const SUPPORT_EMAIL = "info@TimeSafari.app";
export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;

View File

@@ -448,8 +448,8 @@ export const NOTIFY_UNCONFIRMED_HOURS_DYNAMIC = {
};
// Complex modal constants (for raw $notify calls with advanced features)
// MembersList.vue complex modals
// Used in: MembersList.vue (complex modal for adding contacts)
// MeetingMembersList.vue complex modals
// Used in: MeetingMembersList.vue (complex modal for adding contacts)
export const NOTIFY_ADD_CONTACT_FIRST = {
title: "Add as Contact First?",
text: "This person is not in your contacts. Would you like to add them as a contact first?",
@@ -457,7 +457,7 @@ export const NOTIFY_ADD_CONTACT_FIRST = {
noText: "Skip Adding Contact",
};
// Used in: MembersList.vue (complex modal for continuing without adding)
// Used in: MeetingMembersList.vue (complex modal for continuing without adding)
export const NOTIFY_CONTINUE_WITHOUT_ADDING = {
title: "Continue Without Adding?",
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",

View File

@@ -175,6 +175,7 @@ const MIGRATIONS = [
},
{
name: "002_add_iViewContent_to_contacts",
// Note that many times iViewContent was set to null despite the DEFAULT setting.
sql: `
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
@@ -214,12 +215,68 @@ const MIGRATIONS = [
`,
},
{
name: "007_add_reminderFastRolloverForTesting_to_settings",
name: "007_add_hideTheirContent_to_contacts",
// Since we have problems where iViewContent is not set, let's default to show content.
// Add hideTheirContent: null/absent/false = show content (safe default)
sql: `
ALTER TABLE contacts ADD COLUMN hideTheirContent BOOLEAN DEFAULT 0;
UPDATE contacts SET hideTheirContent = CASE WHEN iViewContent = 0 THEN 1 ELSE 0 END WHERE iViewContent IS NOT NULL;
`,
},
{
name: "008_remove_iViewContent_from_contacts",
// Recreate contacts without iViewContent: backup, drop, recreate, restore
sql: `
PRAGMA foreign_keys = OFF;
CREATE TABLE _contact_labels_backup_008 AS SELECT * FROM contact_labels;
DROP TABLE IF EXISTS contact_labels;
CREATE TABLE _contacts_backup_008 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
name TEXT,
contactMethods TEXT,
nextPubKeyHashB64 TEXT,
notes TEXT,
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN,
hideTheirContent BOOLEAN DEFAULT 0
);
INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent)
SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts;
DROP TABLE contacts;
ALTER TABLE _contacts_backup_008 RENAME TO contacts;
-- UNIQUE is important on 'did' because the foreign key from contact_labels fails on mobile without it.
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE contact_labels (
did TEXT NOT NULL,
label TEXT NOT NULL,
PRIMARY KEY (did, label),
FOREIGN KEY (did) REFERENCES contacts(did) ON DELETE CASCADE
);
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008;
DROP TABLE _contact_labels_backup_008;
PRAGMA foreign_keys = ON;
`,
},
{
name: "009_add_reminderFastRolloverForTesting_to_settings",
sql: `
-- Dev/test only: 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes)
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
`,
},
];
/**

View File

@@ -5,6 +5,8 @@ export type ContactMethod = {
};
export type Contact = {
// id is a property in most contacts, but besides sorting from DB we don't need it
//
// When adding a property:
// - Consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
@@ -14,7 +16,8 @@ export type Contact = {
did: string;
contactMethods?: Array<ContactMethod>;
iViewContent?: boolean;
/** When true, hide this contact's activity from the feed. Default (null/undefined) = show. */
hideTheirContent?: boolean;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string;

View File

@@ -55,6 +55,9 @@ export interface AxiosErrorResponse {
response?: {
data?: {
error?: {
// This is in responses from endorser-ch server
userMessage?: string;
// This is the old approach from endorser-ch server; remove when we've removed all "error: { message: ... }"
message?: string;
};
[key: string]: unknown;

View File

@@ -7,6 +7,25 @@ export interface UserInfo {
nextPublicEncKeyHash?: string;
}
export interface MatchPair {
pairNumber: number;
similarity: number;
participants: MatchPairParticipant[];
}
/** Pair from GET/POST /api/partner/groupOnboardMatch */
export interface MatchPairParticipant {
issuerDid: string;
content: string;
// there's a similar structure in MeetingMembersList.vue with extra Member info
decryptedContentObject: {
name: string;
did: string;
isRegistered: boolean;
};
description: string;
}
export interface MemberData {
did: string;
name: string;
@@ -15,3 +34,16 @@ export interface MemberData {
memberId: string;
};
}
export interface DoNotPairGroup {
id: string;
name: string;
colorIndex: number;
memberDids: string[];
}
export interface MeetingExclusionState {
meetingGroupId: string;
excludedDids: string[];
doNotPairGroups: DoNotPairGroup[];
}

View File

@@ -0,0 +1,155 @@
import { Contact } from "../db/tables/contacts";
import { getContactJwtFromJwtUrl } from "./crypto";
import { decodeEndorserJwt } from "./crypto/vc";
export type ContactImportParseErrorCode =
| "not_contact_import_format"
| "truncated_data"
| "invalid_jwt"
| "unsupported_payload";
export type ContactImportParseResult =
| {
kind: "single";
jwt: string;
contact: Contact;
}
| {
kind: "multi";
jwt: string;
contacts: Contact[];
}
| {
kind: "error";
code: ContactImportParseErrorCode;
message: string;
};
function mapSingleContact(payload: Record<string, unknown>): Contact | null {
const own = payload.own as Record<string, unknown> | undefined;
if (!own || typeof own !== "object") {
return null;
}
const didFromOwn = typeof own.did === "string" ? own.did : undefined;
const didFromIss = typeof payload.iss === "string" ? payload.iss : undefined;
const did = didFromOwn || didFromIss;
if (!did) {
return null;
}
const contact: Contact = {
did,
name: typeof own.name === "string" ? own.name : undefined,
registered: Boolean(own.registered),
};
const nextPubKeyHashB64 =
(typeof own.nextPublicEncKeyHash === "string" &&
own.nextPublicEncKeyHash) ||
(typeof own.nextPubKeyHashB64 === "string" && own.nextPubKeyHashB64) ||
undefined;
if (nextPubKeyHashB64) {
contact.nextPubKeyHashB64 = nextPubKeyHashB64;
}
const publicKeyBase64 =
(typeof own.publicEncKey === "string" && own.publicEncKey) ||
(typeof own.publicKeyBase64 === "string" && own.publicKeyBase64) ||
undefined;
if (publicKeyBase64) {
contact.publicKeyBase64 = publicKeyBase64;
}
if (typeof own.profileImageUrl === "string" && own.profileImageUrl) {
contact.profileImageUrl = own.profileImageUrl;
}
return contact;
}
export function parseContactImportInput(
rawInput: string,
): ContactImportParseResult {
const input = rawInput.trim();
if (!input) {
return {
kind: "error",
code: "not_contact_import_format",
message: "No contact import data was found in that input.",
};
}
const looksLikeJwt = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/.test(
input,
);
const hasContactHint =
input.includes("/contact-import") ||
input.includes("contactJwt=") ||
input.includes("/contact?jwt=");
if (
input.endsWith("contact-import") ||
input.endsWith("contact-import/") ||
input.endsWith("/deep-link")
) {
return {
kind: "error",
code: "truncated_data",
message:
"That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
};
}
if (!hasContactHint && !looksLikeJwt) {
return {
kind: "error",
code: "not_contact_import_format",
message: "No contact import data was found in that input.",
};
}
const jwt = getContactJwtFromJwtUrl(input);
if (!jwt) {
return {
kind: "error",
code: "invalid_jwt",
message:
"That contact-import data could not be decoded. Ask for a fresh link or QR code.",
};
}
try {
const payload = decodeEndorserJwt(jwt).payload as Record<string, unknown>;
if (Array.isArray(payload.contacts)) {
return {
kind: "multi",
jwt,
contacts: payload.contacts as Contact[],
};
}
const singleContact = mapSingleContact(payload);
if (singleContact) {
return {
kind: "single",
jwt,
contact: singleContact,
};
}
} catch (_error) {
return {
kind: "error",
code: "invalid_jwt",
message:
"That contact-import data could not be decoded. Ask for a fresh link or QR code.",
};
}
return {
kind: "error",
code: "unsupported_payload",
message:
"That contact-import format is not supported yet. Ask the sender to share again.",
};
}

View File

@@ -676,15 +676,16 @@ export async function setPlanInCache(
/**
* Extracts user-friendly message from server error
* @param {any} error - Error thrown from Endorser server call
* @param {AxiosErrorResponse} error - Error thrown from Endorser server call
* @returns {string|undefined} User-friendly message or undefined if none found
*/
export function serverMessageForUser(error: unknown): string | undefined {
if (error && typeof error === "object" && "response" in error) {
const err = error as AxiosErrorResponse;
return err.response?.data?.error?.message;
}
return undefined;
export function serverMessageForUser(
error: AxiosErrorResponse,
): string | undefined {
return (
error?.response?.data?.error?.userMessage ||
error?.response?.data?.error?.message
);
}
/**
@@ -1262,7 +1263,7 @@ export async function createAndSubmitClaim(
});
const errorMessage: string =
serverMessageForUser(error) ||
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
(error && typeof error === "object" && "message" in error
? String(error.message)
: undefined) ||

View File

@@ -34,6 +34,7 @@ import {
faCircleQuestion,
faCircleRight,
faCircleUser,
faCircleXmark,
faClock,
faCoins,
faComment,
@@ -135,6 +136,7 @@ library.add(
faCircleQuestion,
faCircleRight,
faCircleUser,
faCircleXmark,
faClock,
faCoins,
faComment,

View File

@@ -1,5 +1,7 @@
export interface UserProfile {
description: string;
generateEmbedding?: boolean;
embeddingIsForEmptyString?: boolean;
locLat?: number;
locLon?: number;
locLat2?: number;

View File

@@ -62,6 +62,11 @@ const routes: Array<RouteRecordRaw> = [
name: "contact-import",
component: () => import("../views/ContactImportView.vue"),
},
{
path: "/contact-profile-check",
name: "contact-profile-check",
component: () => import("../views/ContactProfileCheckView.vue"),
},
{
path: "/contact-qr",
name: "contact-qr",
@@ -407,6 +412,18 @@ router.beforeEach(async (to, _from, next) => {
timestamp: new Date().toISOString(),
});
// Store error details so StartView can display them
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack || "" : "";
try {
sessionStorage.setItem(
"startupError",
JSON.stringify({ message: errorMessage, stack: errorStack }),
);
} catch {
// sessionStorage may be unavailable
}
// Redirect to start page if identity creation fails
// This allows users to manually create an identity or troubleshoot
logger.info(

View File

@@ -12,9 +12,7 @@
*
* 1. **Single Application**: Each migration runs exactly once per database
* 2. **Tracked Execution**: All applied migrations are recorded in a migrations table
* 3. **Schema Validation**: Actual database schema is validated before and after migrations
* 4. **Graceful Recovery**: Handles cases where schema exists but tracking is missing
* 5. **Comprehensive Logging**: Detailed logging for debugging and monitoring
* 3. **Comprehensive Logging**: Detailed logging for debugging and monitoring
*
* ## Migration Flow
*
@@ -26,9 +24,7 @@
* b. Check if schema already exists
* c. Skip if already applied
* d. Apply migration SQL
* e. Validate schema was created
* f. Record migration as applied
* 4. Final validation of all migrations
* e. Record migration as applied
* ```
*
* ## Usage Example
@@ -77,25 +73,6 @@ interface Migration {
statements?: string[];
}
/**
* Migration validation result
*
* Contains the results of validating that a migration was successfully
* applied by checking the actual database schema.
*
* @interface MigrationValidation
*/
interface MigrationValidation {
/** Whether the migration validation passed overall */
isValid: boolean;
/** Whether expected tables exist */
tableExists: boolean;
/** Whether expected columns exist */
hasExpectedColumns: boolean;
/** List of validation errors encountered */
errors: string[];
}
/**
* Migration registry to store and manage database migrations
*
@@ -207,354 +184,6 @@ export function registerMigration(migration: Migration): void {
migrationRegistry.registerMigration(migration);
}
/**
* Validate that a migration was successfully applied by checking schema
*
* This function performs post-migration validation to ensure that the
* expected database schema changes were actually applied. It checks for
* the existence of tables, columns, and other schema elements that should
* have been created by the migration.
*
* @param migration - The migration to validate
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to validation results
*
* @example
* ```typescript
* const validation = await validateMigrationApplication(migration, sqlQuery);
* if (!validation.isValid) {
* console.error('Migration validation failed:', validation.errors);
* }
* ```
*/
/**
* Helper function to check if a SQLite result indicates a table exists
* @param result - The result from a sqlite_master query
* @returns true if the table exists
*/
function checkSqliteTableResult(result: unknown): boolean {
return (
(result as unknown as { values: unknown[][] })?.values?.length > 0 ||
(Array.isArray(result) && result.length > 0)
);
}
/**
* Helper function to validate that a table exists in the database
* @param tableName - Name of the table to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if table exists
*/
async function validateTableExists<T>(
tableName: string,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
try {
const result = await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`,
);
return checkSqliteTableResult(result);
} catch (error) {
logger.error(`❌ [Validation] Error checking table ${tableName}:`, error);
return false;
}
}
/**
* Helper function to validate that a column exists in a table
* @param tableName - Name of the table
* @param columnName - Name of the column to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if column exists
*/
async function validateColumnExists<T>(
tableName: string,
columnName: string,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
try {
await sqlQuery(`SELECT ${columnName} FROM ${tableName} LIMIT 1`);
return true;
} catch (error) {
logger.error(
`❌ [Validation] Error checking column ${columnName} in ${tableName}:`,
error,
);
return false;
}
}
/**
* Helper function to validate multiple tables exist
* @param tableNames - Array of table names to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to array of validation results
*/
async function validateMultipleTables<T>(
tableNames: string[],
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<{ exists: boolean; missing: string[] }> {
const missing: string[] = [];
for (const tableName of tableNames) {
const exists = await validateTableExists(tableName, sqlQuery);
if (!exists) {
missing.push(tableName);
}
}
return {
exists: missing.length === 0,
missing,
};
}
/**
* Helper function to add validation error with consistent logging
* @param validation - The validation object to update
* @param message - Error message to add
* @param error - The error object for logging
*/
function addValidationError(
validation: MigrationValidation,
message: string,
error: unknown,
): void {
validation.isValid = false;
validation.errors.push(message);
logger.error(`❌ [Migration-Validation] ${message}:`, error);
}
async function validateMigrationApplication<T>(
migration: Migration,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<MigrationValidation> {
const validation: MigrationValidation = {
isValid: true,
tableExists: false,
hasExpectedColumns: false,
errors: [],
};
try {
if (migration.name === "001_initial") {
// Validate core tables exist for initial migration
const tables = [
"accounts",
"secret",
"settings",
"contacts",
"logs",
"temp",
];
const tableValidation = await validateMultipleTables(tables, sqlQuery);
if (!tableValidation.exists) {
validation.isValid = false;
validation.errors.push(
`Missing tables: ${tableValidation.missing.join(", ")}`,
);
logger.error(
`❌ [Migration-Validation] Missing tables:`,
tableValidation.missing,
);
}
validation.tableExists = tableValidation.exists;
} else if (migration.name === "002_add_iViewContent_to_contacts") {
// Validate iViewContent column exists in contacts table
const columnExists = await validateColumnExists(
"contacts",
"iViewContent",
sqlQuery,
);
if (!columnExists) {
addValidationError(
validation,
"Column iViewContent missing from contacts table",
new Error("Column not found"),
);
} else {
validation.hasExpectedColumns = true;
}
} else if (migration.name === "004_active_identity_management") {
// Validate active_identity table exists and has correct structure
const activeIdentityExists = await validateTableExists(
"active_identity",
sqlQuery,
);
if (!activeIdentityExists) {
addValidationError(
validation,
"Table active_identity missing",
new Error("Table not found"),
);
} else {
validation.tableExists = true;
// Check that active_identity has the expected structure
const hasExpectedColumns = await validateColumnExists(
"active_identity",
"id, activeDid, lastUpdated",
sqlQuery,
);
if (!hasExpectedColumns) {
addValidationError(
validation,
"active_identity table missing expected columns",
new Error("Columns not found"),
);
} else {
validation.hasExpectedColumns = true;
}
}
// Check that hasBackedUpSeed column exists in settings table
// Note: This validation is included here because migration 004 is consolidated
// and includes the functionality from the original migration 003
const hasBackedUpSeedExists = await validateColumnExists(
"settings",
"hasBackedUpSeed",
sqlQuery,
);
if (!hasBackedUpSeedExists) {
addValidationError(
validation,
"Column hasBackedUpSeed missing from settings table",
new Error("Column not found"),
);
}
}
// Add validation for future migrations here
// } else if (migration.name === "003_future_migration") {
// // Validate future migration schema changes
// }
} catch (error) {
validation.isValid = false;
validation.errors.push(`Validation error: ${error}`);
logger.error(
`❌ [Migration-Validation] Validation failed for ${migration.name}:`,
error,
);
}
return validation;
}
/**
* Check if migration is already applied by examining actual schema
*
* This function performs schema introspection to determine if a migration
* has already been applied, even if it's not recorded in the migrations
* table. This is useful for handling cases where the database schema exists
* but the migration tracking got out of sync.
*
* @param migration - The migration to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if schema already exists
*
* @example
* ```typescript
* const schemaExists = await isSchemaAlreadyPresent(migration, sqlQuery);
* if (schemaExists) {
* console.log('Schema already exists, skipping migration');
* }
* ```
*/
async function isSchemaAlreadyPresent<T>(
migration: Migration,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
try {
if (migration.name === "001_initial") {
// Check if accounts table exists (primary indicator of initial migration)
const result = (await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'`,
)) as unknown as { values: unknown[][] };
const hasTable =
result?.values?.length > 0 ||
(Array.isArray(result) && result.length > 0);
// Reduced logging - only log on error
return hasTable;
} else if (migration.name === "002_add_iViewContent_to_contacts") {
// Check if iViewContent column exists in contacts table
try {
await sqlQuery(`SELECT iViewContent FROM contacts LIMIT 1`);
// Reduced logging - only log on error
return true;
} catch (error) {
// Reduced logging - only log on error
return false;
}
} else if (migration.name === "003_add_hasBackedUpSeed_to_settings") {
// Check if hasBackedUpSeed column exists in settings table
try {
await sqlQuery(`SELECT hasBackedUpSeed FROM settings LIMIT 1`);
return true;
} catch (error) {
return false;
}
} else if (migration.name === "004_active_identity_management") {
// Check if active_identity table exists and has correct structure
try {
// Check that active_identity table exists
const activeIdentityResult = await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='active_identity'`,
);
const hasActiveIdentityTable =
(activeIdentityResult as unknown as { values: unknown[][] })?.values
?.length > 0 ||
(Array.isArray(activeIdentityResult) &&
activeIdentityResult.length > 0);
if (!hasActiveIdentityTable) {
return false;
}
// Check that active_identity has the expected structure
try {
await sqlQuery(
`SELECT id, activeDid, lastUpdated FROM active_identity LIMIT 1`,
);
// Also check that hasBackedUpSeed column exists in settings
// This is included because migration 004 is consolidated
try {
await sqlQuery(`SELECT hasBackedUpSeed FROM settings LIMIT 1`);
return true;
} catch (error) {
return false;
}
} catch (error) {
return false;
}
} catch (error) {
logger.error(
`🔍 [Migration-Schema] Schema check failed for ${migration.name}, assuming not present:`,
error,
);
return false;
}
}
// Add schema checks for future migrations here
// } else if (migration.name === "003_future_migration") {
// // Check if future migration schema already exists
// }
} catch (error) {
logger.error(
`🔍 [Migration-Schema] Schema check failed for ${migration.name}, assuming not present:`,
error,
);
return false;
}
return false;
}
/**
* Run all registered migrations against the database
*
@@ -600,7 +229,7 @@ export async function runMigrations<T>(
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
try {
logger.debug("📋 [Migration] Starting migration process...");
logger.debug("[Migration] Starting migration process...");
// Create migrations table if it doesn't exist
// Note: We use IF NOT EXISTS here because this is infrastructure, not a business migration
@@ -628,7 +257,7 @@ export async function runMigrations<T>(
// Only log migration counts in development
logger.debug(
`📊 [Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`,
`[Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`,
);
let appliedCount = 0;
@@ -645,70 +274,35 @@ export async function runMigrations<T>(
continue;
}
// Check 2: Does the schema already exist in the database?
const isSchemaPresent = await isSchemaAlreadyPresent(migration, sqlQuery);
// Handle case where schema exists but isn't recorded
if (isSchemaPresent) {
try {
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.debug(
`✅ [Migration] Marked existing schema as applied: ${migration.name}`,
);
skippedCount++;
continue;
} catch (insertError) {
logger.warn(
`⚠️ [Migration] Could not record existing schema ${migration.name}:`,
insertError,
);
// Continue with normal migration process as fallback
}
}
// Apply the migration
logger.debug(`🔄 [Migration] Applying migration: ${migration.name}`);
logger.debug(`[Migration] Applying migration: ${migration.name}`);
try {
// Execute the migration SQL as single atomic operation
logger.debug(`🔧 [Migration] Executing SQL for: ${migration.name}`);
logger.debug(`🔧 [Migration] SQL content: ${migration.sql}`);
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
logger.debug(`[Migration] SQL content: ${migration.sql}`);
// Execute the migration SQL directly - it should be atomic
// The SQL itself should handle any necessary transactions
const execResult = await sqlExec(migration.sql);
logger.debug(
`🔧 [Migration] SQL execution result: ${JSON.stringify(execResult)}`,
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
);
// Validate the migration was applied correctly
const validation = await validateMigrationApplication(
migration,
sqlQuery,
);
if (!validation.isValid) {
logger.warn(
`⚠️ [Migration] Validation failed for ${migration.name}:`,
validation.errors,
);
}
// Record that the migration was applied
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.debug(`🎉 [Migration] Successfully applied: ${migration.name}`);
logger.debug(` [Migration] Successfully applied: ${migration.name}`);
appliedCount++;
} catch (error) {
logger.error(`❌ [Migration] Error applying ${migration.name}:`, error);
// Provide explicit rollback instructions for migration failures
logger.error(
`🔄 [Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
`[Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
);
logger.error(` 1. Stop the application immediately`);
logger.error(
@@ -740,37 +334,8 @@ export async function runMigrations<T>(
errorMessage.includes("already exists"))
) {
logger.debug(
`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}). Validating and marking as complete.`,
`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}).`,
);
// Validate the existing schema
const validation = await validateMigrationApplication(
migration,
sqlQuery,
);
if (!validation.isValid) {
logger.warn(
`⚠️ [Migration] Schema validation failed for ${migration.name}:`,
validation.errors,
);
// Don't mark as applied if validation fails
continue;
}
// Mark the migration as applied since the schema change already exists
try {
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.debug(`✅ [Migration] Marked as applied: ${migration.name}`);
appliedCount++;
} catch (insertError) {
// If we can't insert the migration record, log it but don't fail
logger.warn(
`⚠️ [Migration] Could not record ${migration.name} as applied:`,
insertError,
);
}
} else {
// For other types of errors, still fail the migration
logger.error(
@@ -800,11 +365,10 @@ export async function runMigrations<T>(
// Only show completion message in development
logger.log(
`🎉 [Migration] Migration process complete! Summary: ${appliedCount} applied, ${skippedCount} skipped`,
`[Migration] Migration process complete. Summary: ${appliedCount} applied, ${skippedCount} skipped`,
);
} catch (error) {
logger.error("\n💥 [Migration] Migration process failed:", error);
logger.error("[MigrationService] Migration process failed:", error);
logger.error(" [Migration] Migration process failed:", error);
throw error;
}
}

View File

@@ -714,7 +714,7 @@ export class CapacitorPlatformService
*
* For critical tables like `contacts`, the method validates:
* - Table structure using `PRAGMA table_info`
* - Presence of important columns (e.g., `iViewContent`)
* - Presence of important columns (e.g., `hideTheirContent`)
* - Column data types and constraints
*
* ## Error Handling:
@@ -784,7 +784,7 @@ export class CapacitorPlatformService
}
}
// Step 3: Check contacts table schema (including iViewContent column)
// Step 3: Check contacts table schema (including hideTheirContent column)
if (existingTables.includes("contacts")) {
try {
const contactsSchema = await this.db.query(
@@ -795,23 +795,23 @@ export class CapacitorPlatformService
contactsSchema,
);
// Check for iViewContent column specifically
const hasIViewContent = contactsSchema.values?.some(
// Check for hideTheirContent column specifically
const hasIHideContent = contactsSchema.values?.some(
(col: unknown) =>
(typeof col === "object" &&
col !== null &&
"name" in col &&
(col as { name: string }).name === "iViewContent") ||
(Array.isArray(col) && col[1] === "iViewContent"),
(col as { name: string }).name === "hideTheirContent") ||
(Array.isArray(col) && col[1] === "hideTheirContent"),
);
if (hasIViewContent) {
if (hasIHideContent) {
logger.debug(
`✅ [DB-Integrity] iViewContent column exists in contacts table`,
`✅ [DB-Integrity] hideTheirContent column exists in contacts table`,
);
} else {
logger.error(
`❌ [DB-Integrity] iViewContent column missing from contacts table`,
`❌ [DB-Integrity] hideTheirContent column missing from contacts table`,
);
}
} catch (error) {
@@ -1055,8 +1055,8 @@ export class CapacitorPlatformService
// Offer to share the file
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your TimeSafari backup file.",
title: "Backup",
text: "Here is your backup file.",
url: writeResult.uri,
dialogTitle: "Share your backup",
});
@@ -1100,8 +1100,8 @@ export class CapacitorPlatformService
// Then share the file to let user choose where to save it
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your TimeSafari backup file.",
title: "Backup",
text: "Here is your backup file.",
url: writeResult.uri,
dialogTitle: "Save your backup",
});
@@ -1180,7 +1180,7 @@ export class CapacitorPlatformService
});
await Share.share({
title: "TimeSafari Backup",
title: "Backup",
text: "Here is your backup file.",
url: uri,
dialogTitle: "Share your backup file",

View File

@@ -2,15 +2,6 @@
<div>
<h2>EntityGrid Function Prop Test</h2>
<div class="mb-4">
<button @click="toggleCustomFunction">
{{ useCustomFunction ? "Use Default" : "Use Custom Function" }}
</button>
<span class="ml-2"
>Current: {{ useCustomFunction ? "Custom" : "Default" }}</span
>
</div>
<div class="mb-4">
<h3>
People Grid ({{ people.length }} total,
@@ -23,9 +14,6 @@
:all-my-dids="allMyDids"
:all-contacts="people"
:conflict-checker="conflictChecker"
:display-entities-function="
useCustomFunction ? customPeopleFunction : undefined
"
@entity-selected="handleEntitySelected"
/>
</div>
@@ -42,9 +30,6 @@
:all-my-dids="allMyDids"
:all-contacts="people"
:conflict-checker="conflictChecker"
:display-entities-function="
useCustomFunction ? customProjectsFunction : undefined
"
@entity-selected="handleEntitySelected"
/>
</div>
@@ -74,7 +59,6 @@ import { PlanData } from "../interfaces/records";
},
})
export default class EntityGridFunctionPropTest extends Vue {
useCustomFunction = false;
selectedEntity: {
type: "person" | "project" | "special";
entityType?: string;
@@ -144,26 +128,6 @@ export default class EntityGridFunctionPropTest extends Vue {
},
];
/**
* Custom function for people: only show those with profile images
*/
customPeopleFunction = (
entities: Contact[],
_entityType: string,
): Contact[] => {
return entities.filter((person) => person.profileImageUrl);
};
/**
* Custom function for projects: sort by name and limit to 3
*/
customProjectsFunction = (
entities: PlanData[],
_entityType: string,
): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
};
/**
* Simple conflict checker for testing
*/
@@ -171,13 +135,6 @@ export default class EntityGridFunctionPropTest extends Vue {
return did === this.activeDid;
};
/**
* Toggle between custom and default display functions
*/
toggleCustomFunction(): void {
this.useCustomFunction = !this.useCustomFunction;
}
/**
* Handle entity selection
*/
@@ -193,16 +150,10 @@ export default class EntityGridFunctionPropTest extends Vue {
* Computed properties to show display counts
*/
get displayedPeopleCount(): number {
if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people").length;
}
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
}
get displayedProjectsCount(): number {
if (this.useCustomFunction) {
return this.customProjectsFunction(this.projects, "projects").length;
}
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
}
}

View File

@@ -114,8 +114,7 @@ export interface DatabaseExport {
const _memoryLogs: string[] = [];
/**
* Enhanced mixin that provides cached platform service access and utility methods
* with smart caching layer for ultimate performance optimization
* Enhanced mixin that provides platform utility methods
*/
export const PlatformServiceMixin = {
data() {
@@ -298,7 +297,7 @@ export const PlatformServiceMixin = {
column === "warnIfTestServer" ||
column === "reminderFastRolloverForTesting" ||
// contacts
column === "iViewContent" ||
column === "hideTheirContent" ||
column === "registered" ||
column === "seesMe"
) {
@@ -913,7 +912,7 @@ export const PlatformServiceMixin = {
// Create a new contact object with proper typing
const normalizedContact: Contact = {
did: contact.did,
iViewContent: contact.iViewContent,
hideTheirContent: contact.hideTheirContent,
name: contact.name,
nextPubKeyHashB64: contact.nextPubKeyHashB64,
notes: contact.notes,
@@ -1012,7 +1011,7 @@ export const PlatformServiceMixin = {
},
/**
* Load settings with optional defaults WITHOUT caching - $settings()
* Load settings with optional defaults - $settings()
* Settings are loaded fresh every time for immediate consistency
* @param defaults Optional default values
* @returns Fresh settings object from database
@@ -1035,11 +1034,11 @@ export const PlatformServiceMixin = {
settings.apiServer = DEFAULT_ENDORSER_API_SERVER;
}
return settings; // Return fresh data without caching
return settings;
},
/**
* Load account-specific settings WITHOUT caching - $accountSettings()
* Load account-specific settings - $accountSettings()
* Settings are loaded fresh every time for immediate consistency
* @param did DID identifier (optional, uses current active DID)
* @param defaults Optional default values
@@ -1117,8 +1116,8 @@ export const PlatformServiceMixin = {
// =================================================
/**
* Save default settings - $saveSettings()
* Ultra-concise shortcut for updateDefaultSettings
* Save settings for currently active DID
* May be consolidated with $saveUserSettings()
*
* ✅ KEEP: This method will be the primary settings save method after consolidation
*
@@ -1206,8 +1205,8 @@ export const PlatformServiceMixin = {
},
/**
* Save user-specific settings - $saveUserSettings()
* Ultra-concise shortcut for updateDidSpecificSettings
* Save DID-specific settings - $saveUserSettings()
*
* @param did DID identifier
* @param changes Settings changes to save
* @returns Promise<boolean> Success status
@@ -1382,8 +1381,10 @@ export const PlatformServiceMixin = {
? contact.profileImageUrl
: null,
notes: contact.notes !== undefined ? contact.notes : null,
iViewContent:
contact.iViewContent !== undefined ? contact.iViewContent : null,
hideTheirContent:
contact.hideTheirContent !== undefined
? contact.hideTheirContent
: null,
contactMethods:
contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods)
@@ -1394,7 +1395,7 @@ export const PlatformServiceMixin = {
await this.$dbExec(
`INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, hideTheirContent, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
safeContact.did,
@@ -1405,7 +1406,7 @@ export const PlatformServiceMixin = {
safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.hideTheirContent,
safeContact.contactMethods,
],
);
@@ -1732,8 +1733,9 @@ export const PlatformServiceMixin = {
// =================================================
/**
* Get temporary data by ID - $getTemp()
* Retrieves temporary data from the temp table
* Get data from temp table by ID
* Currently set by main.capacitor.ts storeSharedImageInTempDB()
*
* @param id Temporary storage ID
* @returns Promise<Temp | null> Temporary data or null if not found
*/
@@ -1747,8 +1749,8 @@ export const PlatformServiceMixin = {
},
/**
* Delete temporary data by ID - $deleteTemp()
* Removes temporary data from the temp table
* Delete data from temp table by ID
*
* @param id Temporary storage ID
* @returns Promise<boolean> Success status
*/
@@ -2243,7 +2245,7 @@ export const PlatformServiceMixin = {
// =================================================
/**
* Enhanced interface with caching utility methods
* Enhanced interface
*/
export interface IPlatformServiceMixin {
platformService: PlatformService;

View File

@@ -780,7 +780,6 @@ import {
DEFAULT_PARTNER_API_SERVER,
DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE,
isNotProdServer as isNotProdServerUtil,
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
@@ -937,14 +936,6 @@ export default class AccountViewView extends Vue {
return Capacitor.isNativePlatform();
}
/**
* True when not on prod API server (test/local), so dev-only UI (e.g. 10-minute rollover toggle) is shown.
* Matches logic used in ImportAccountView and TestView.
*/
private get isNotProdServer(): boolean {
return isNotProdServerUtil(this.apiServer);
}
created() {
this.notify = createNotifyHelpers(this.$notify);
@@ -1804,7 +1795,7 @@ export default class AccountViewView extends Vue {
} else {
logger.error("Non-success deleting image:", response);
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_PROBLEM);
// keep the imageUrl in localStorage so the user can try again if they want
// keep the profileImageUrl so the user can try again if they want
}
} catch (error) {
if (isApiError(error) && error.response?.status === 404) {

View File

@@ -55,6 +55,9 @@
<!-- Contact Methods -->
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700"
>Contact Methods</label
>
<div v-for="(method, index) in contactMethods" :key="index" class="mt-4">
<!-- Type and Value Row -->
<div class="flex gap-2">

View File

@@ -197,16 +197,30 @@
Import Contacts
</button>
</ul>
<p v-else-if="contactsImporting.length > 0">
<div v-else-if="contactsImporting.length > 0">
<p>
All those contacts are already in your list with the same information.
</p>
<div class="mt-3 flex flex-col items-center gap-2">
<button
v-if="applyLabelsToExisting"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
@click="importContacts"
data-testId="copyUnsignedImportLinkButton"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white px-2 py-1.5 rounded w-fit"
@click="copyUnsignedImportLink"
>
Copy Unsigned Link for These Contacts
</button>
<button
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white px-2 py-1.5 rounded w-fit"
:class="{
'opacity-50 cursor-not-allowed': !canApplyLabelsToExisting,
}"
@click="handleApplyLabelsToExistingClick"
>
Apply Labels to Existing Contacts
</button>
</p>
</div>
</div>
<div v-else>
There are no contacts in that import. If some were sent, try again to
get the full text and paste it. (Note that iOS cuts off data in text
@@ -292,6 +306,7 @@ import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import OfferDialog from "../components/OfferDialog.vue";
import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
import { copyToClipboard } from "../services/ClipboardService";
import {
Contact,
ContactWithLabels,
@@ -303,8 +318,7 @@ import {
errorStringForLog,
setVisibilityUtil,
} from "../libs/endorserServer";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
import { parseContactImportInput } from "../libs/contactImportPayload";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ContactLabel } from "@/db/tables/contactLabels";
@@ -495,27 +509,29 @@ export default class ContactImportView extends Vue {
* Processes JWT from URL path and handles different JWT formats
*/
private async processJwtFromPath() {
// JWT tokens always start with 'ey' (base64url encoded header)
const JWT_PATTERN = /\/contact-import\/(ey.+)$/;
const jwt = window.location.pathname.match(JWT_PATTERN)?.[1];
const parsedImport = parseContactImportInput(window.location.pathname);
if (parsedImport.kind === "error") {
return;
}
if (jwt) {
const parsedJwt = decodeEndorserJwt(jwt);
const contacts: Array<Contact> =
parsedJwt.payload.contacts ||
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
if (!contacts && parsedJwt.payload.own) {
if (parsedImport.kind === "single") {
this.$router.push({
name: "contacts",
query: { contactJwt: jwt },
query: { contactJwt: parsedImport.jwt },
});
return;
}
if (contacts) {
await this.setContactsSelected(contacts);
if (parsedImport.contacts.length === 1) {
this.$router.push({
name: "contacts",
query: { contactJwt: parsedImport.jwt },
});
return;
}
if (parsedImport.contacts.length > 0) {
await this.setContactsSelected(parsedImport.contacts);
}
}
@@ -595,16 +611,12 @@ export default class ContactImportView extends Vue {
* @param jwtInput JWT string to validate
*/
async checkContactJwt(jwtInput: string) {
const parsedImport = parseContactImportInput(jwtInput);
if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("contact-import") ||
jwtInput.endsWith("contact-import/")
parsedImport.kind === "error" &&
parsedImport.code === "truncated_data"
) {
this.notify.error(
"That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
TIMEOUTS.LONG,
);
this.notify.error(parsedImport.message, TIMEOUTS.LONG);
}
}
@@ -616,14 +628,29 @@ export default class ContactImportView extends Vue {
this.checkingImports = true;
try {
const jwt: string = getContactJwtFromJwtUrl(jwtInput) || "";
const payload = decodeEndorserJwt(jwt).payload;
if (Array.isArray(payload.contacts)) {
await this.setContactsSelected(payload.contacts);
} else {
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
const parsedImport = parseContactImportInput(jwtInput);
if (parsedImport.kind === "error") {
this.notify.error(parsedImport.message, TIMEOUTS.STANDARD);
return;
}
if (parsedImport.kind === "single") {
await this.$router.push({
name: "contacts",
query: { contactJwt: parsedImport.jwt },
});
return;
}
if (parsedImport.contacts.length === 1) {
await this.$router.push({
name: "contacts",
query: { contactJwt: parsedImport.jwt },
});
return;
}
await this.setContactsSelected(parsedImport.contacts);
} catch (error) {
const fullError = "Error importing contacts: " + errorStringForLog(error);
this.$logAndConsole(fullError, true);
@@ -635,6 +662,52 @@ export default class ContactImportView extends Vue {
this.checkingImports = false;
}
private buildUnsignedImportLink(): string {
const contactsForLink: Array<Contact> = this.contactsImporting.map((c) => {
const contact: Contact = {
did: c.did,
};
if (c.name) {
contact.name = c.name;
}
if (c.nextPubKeyHashB64) {
contact.nextPubKeyHashB64 = c.nextPubKeyHashB64;
}
if (c.profileImageUrl) {
contact.profileImageUrl = c.profileImageUrl;
}
if (c.publicKeyBase64) {
contact.publicKeyBase64 = c.publicKeyBase64;
}
if (typeof c.registered === "boolean") {
contact.registered = c.registered;
}
return contact;
});
const contactsParam = encodeURIComponent(JSON.stringify(contactsForLink));
return `${APP_SERVER}/deep-link/contact-import?contacts=${contactsParam}`;
}
async copyUnsignedImportLink() {
if (this.contactsImporting.length === 0) {
this.notify.error(
"No contacts are loaded to build a link.",
TIMEOUTS.SHORT,
);
return;
}
try {
const link = this.buildUnsignedImportLink();
await copyToClipboard(link);
this.notify.copied("unsigned contact import link", TIMEOUTS.STANDARD);
} catch (error) {
const fullError =
"Error copying unsigned import link: " + errorStringForLog(error);
this.$logAndConsole(fullError, true);
this.notify.error("Failed to copy link to clipboard.", TIMEOUTS.STANDARD);
}
}
/**
* Adds a new label to the selected labels list
*/
@@ -791,5 +864,20 @@ export default class ContactImportView extends Vue {
);
this.$router.push({ name: "contacts" });
}
private get canApplyLabelsToExisting(): boolean {
return this.applyLabelsToExisting && this.selectedLabels.length > 0;
}
private async handleApplyLabelsToExistingClick() {
if (!this.canApplyLabelsToExisting) {
this.notify.warning(
`You must choose some labels and check the "Apply" checkbox to use this.`,
TIMEOUTS.LONG,
);
return;
}
await this.importContacts();
}
}
</script>

View File

@@ -0,0 +1,589 @@
<template>
<QuickNav selected="Contacts" />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<TopMessage />
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Profile Check
</h1>
<button
class="order-first text-lg text-center leading-none p-1"
@click="goBack()"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</button>
</div>
<div
v-if="contactResults.length === 0"
class="text-center text-slate-500 mt-8"
>
No contacts to check. Go back and select contacts first.
</div>
<div v-else>
<div class="mb-4 text-sm text-slate-600">
Checked {{ completedCount }} of {{ contactResults.length }}
{{ contactResults.length === 1 ? "contact" : "contacts" }}
</div>
<div class="w-full bg-gray-200 rounded-full h-2 mb-6">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: progressPercent + '%' }"
/>
</div>
<div v-if="isComplete" class="mb-6 p-4 bg-slate-100 rounded-lg">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-semibold text-slate-800">
<span class="text-green-600">{{ fullyReadyCount }}</span>
of {{ contactResults.length }}
{{ contactResults.length === 1 ? "contact" : "contacts" }}
fully ready
</div>
<ul
v-if="fullyReadyCount < contactResults.length"
class="mt-2 text-sm text-slate-600 list-disc list-inside space-y-0.5"
>
<li v-if="blankProfileCount > 0">
<span class="text-amber-600 font-medium">{{
blankProfileCount
}}</span>
{{ blankProfileCount === 1 ? "profile" : "profiles" }} with
blank description
</li>
<li v-if="noProfileOrHiddenCount > 0">
<span class="text-amber-600 font-medium">{{
noProfileOrHiddenCount
}}</span>
no profile or not visible
</li>
<li v-if="noEmbeddingOrHiddenCount > 0">
<span class="text-amber-600 font-medium">{{
noEmbeddingOrHiddenCount
}}</span>
no embedding or not visible
</li>
<li v-if="errorCount > 0">
<span class="text-red-600 font-medium">{{ errorCount }}</span>
{{ errorCount === 1 ? "check" : "checks" }} failed
</li>
</ul>
</div>
<button
class="flex-shrink-0 text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isRefreshing"
@click="refreshChecks"
>
<font-awesome
icon="rotate"
class="fa-fw mr-1"
:class="{ 'fa-spin': isRefreshing }"
/>
{{ isRefreshing ? "Refreshing…" : "Refresh" }}
</button>
</div>
</div>
<ul class="space-y-3">
<li
v-for="result in contactResults"
:key="result.did"
class="bg-slate-50 rounded-lg p-4 border border-slate-200"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span v-if="result.status === 'pending'" class="text-slate-400">
<font-awesome icon="clock" class="fa-fw" />
</span>
<span
v-else-if="result.status === 'checking'"
class="text-blue-500"
>
<font-awesome icon="spinner" class="fa-spin-pulse fa-fw" />
</span>
<span
v-else-if="result.status === 'has-profile'"
:class="
result.embeddingMetadata?.generateEmbedding &&
!result.embeddingMetadata?.isForEmptyString
? 'text-green-600'
: 'text-amber-500'
"
>
<font-awesome
:icon="
result.embeddingMetadata?.generateEmbedding &&
!result.embeddingMetadata?.isForEmptyString
? 'circle-check'
: 'circle-xmark'
"
class="fa-fw"
/>
</span>
<span
v-else-if="result.status === 'blank-profile'"
class="text-amber-500"
>
<font-awesome icon="circle-xmark" class="fa-fw" />
</span>
<span
v-else-if="result.status === 'no-profile'"
class="text-amber-500"
>
<font-awesome icon="circle-xmark" class="fa-fw" />
</span>
<span
v-else-if="result.status === 'error'"
class="text-red-500"
>
<font-awesome icon="triangle-exclamation" class="fa-fw" />
</span>
<span class="font-medium text-slate-800 truncate">
{{ result.name || "(no name)" }}
</span>
</div>
<div class="mt-1 text-sm">
<span v-if="result.status === 'pending'" class="text-slate-400">
Waiting
</span>
<span
v-else-if="result.status === 'checking'"
class="text-blue-500"
>
Checking profile
</span>
<span
v-else-if="result.status === 'has-profile'"
class="text-green-700"
>
Has profile
</span>
<span
v-else-if="result.status === 'blank-profile'"
class="text-amber-600"
>
Has profile with blank description
</span>
<span
v-else-if="result.status === 'no-profile'"
class="text-amber-600"
>
No profile or not visible
</span>
<span
v-else-if="result.status === 'error'"
class="text-red-600"
>
Error: {{ result.error }}
</span>
</div>
<div
v-if="
result.status === 'has-profile' && result.profileDescription
"
class="mt-2 text-sm text-slate-600 line-clamp-2"
>
{{ result.profileDescription }}
</div>
<div
v-if="
result.status !== 'pending' && result.status !== 'checking'
"
class="mt-2 flex items-center gap-2"
>
<button
type="button"
role="switch"
:aria-checked="
result.embeddingMetadata?.generateEmbedding ?? false
"
:disabled="embeddingSavingDids.has(result.did)"
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"
:class="
(result.embeddingMetadata?.generateEmbedding ?? false)
? 'bg-blue-600'
: 'bg-gray-200'
"
@click="toggleEmbedding(result.did)"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition"
:class="
(result.embeddingMetadata?.generateEmbedding ?? false)
? 'translate-x-4'
: 'translate-x-0'
"
/>
</button>
<span class="text-xs text-gray-600">
Embedding:
{{
(result.embeddingMetadata?.generateEmbedding ?? false)
? "On"
: "Off"
}}
<span v-if="embeddingSavingDids.has(result.did)" class="ml-1"
>(saving…)</span
>
</span>
</div>
</div>
<router-link
:to="{ name: 'did', params: { did: result.did } }"
class="flex-shrink-0 text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
>
Contact
</router-link>
</div>
</li>
</ul>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import {
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
} from "../constants/app";
import { getHeaders } from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
interface EmbeddingMetadata {
generateEmbedding: boolean;
isForEmptyString: boolean;
}
interface ContactCheckResult {
did: string;
name: string;
status:
| "pending"
| "checking"
| "has-profile"
| "blank-profile"
| "no-profile"
| "error";
profileDescription?: string;
embeddingMetadata?: EmbeddingMetadata | null;
error?: string;
}
@Component({
name: "ContactProfileCheckView",
components: {
QuickNav,
TopMessage,
},
mixins: [PlatformServiceMixin],
})
export default class ContactProfileCheckView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
notify!: ReturnType<typeof createNotifyHelpers>;
activeDid = "";
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
contactResults: ContactCheckResult[] = [];
embeddingSavingDids: Set<string> = new Set();
isRefreshing = false;
created() {
this.notify = createNotifyHelpers(this.$notify);
}
async mounted() {
const settings = await this.$accountSettings();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.partnerApiServer =
(settings.partnerApiServer as string) || DEFAULT_PARTNER_API_SERVER;
const didsParam = this.$route.query.dids as string;
if (!didsParam) {
return;
}
const isFresh = sessionStorage.getItem("profileCheckFresh") === "true";
sessionStorage.removeItem("profileCheckFresh");
if (!isFresh) {
const cachedDids = sessionStorage.getItem("profileCheckDids");
const cachedResults = sessionStorage.getItem("profileCheckResults");
if (cachedDids === didsParam && cachedResults) {
try {
this.contactResults = JSON.parse(cachedResults);
return;
} catch {
// fall through to fresh load
}
}
}
sessionStorage.removeItem("profileCheckResults");
sessionStorage.removeItem("profileCheckDids");
let dids: string[];
try {
dids = JSON.parse(didsParam);
} catch {
this.notify.error("Invalid contact data.", TIMEOUTS.LONG);
return;
}
for (const did of dids) {
const contact = await this.$getContact(did);
this.contactResults.push({
did,
name: contact?.name || "",
status: "pending",
});
}
await this.runChecks();
}
get completedCount(): number {
return this.contactResults.filter(
(r) => r.status !== "pending" && r.status !== "checking",
).length;
}
get progressPercent(): number {
if (this.contactResults.length === 0) return 0;
return Math.round((this.completedCount / this.contactResults.length) * 100);
}
get isComplete(): boolean {
return (
this.contactResults.length > 0 &&
this.completedCount === this.contactResults.length
);
}
get profileCount(): number {
return this.contactResults.filter((r) => r.status === "has-profile").length;
}
get blankProfileCount(): number {
return this.contactResults.filter((r) => r.status === "blank-profile")
.length;
}
get noProfileOrHiddenCount(): number {
return this.contactResults.filter((r) => r.status === "no-profile").length;
}
get errorCount(): number {
return this.contactResults.filter((r) => r.status === "error").length;
}
get noEmbeddingOrHiddenCount(): number {
return this.contactResults.filter(
(r) =>
r.status !== "pending" &&
r.status !== "checking" &&
!r.embeddingMetadata?.generateEmbedding,
).length;
}
get fullyReadyCount(): number {
return this.contactResults.filter(
(r) =>
r.status === "has-profile" &&
r.embeddingMetadata?.generateEmbedding &&
!r.embeddingMetadata?.isForEmptyString,
).length;
}
private async runChecks() {
if (!this.activeDid) {
this.notify.error("No active identity.", TIMEOUTS.LONG);
return;
}
for (let i = 0; i < this.contactResults.length; i++) {
this.contactResults[i].status = "checking";
this.contactResults = [...this.contactResults];
try {
const headers = await getHeaders(this.activeDid);
const url =
`${this.partnerApiServer}/api/partner/userProfileForIssuer/` +
encodeURIComponent(this.contactResults[i].did);
const response = await this.axios.get(url, { headers });
const data = response.data?.data;
if (data && data.description) {
this.contactResults[i].status = "has-profile";
this.contactResults[i].profileDescription = data.description;
} else if (data && typeof data.description === "string") {
this.contactResults[i].status = "blank-profile";
} else {
this.contactResults[i].status = "no-profile";
}
} catch (err: unknown) {
const axiosErr = err as {
response?: { status?: number; data?: { error?: string } };
};
if (axiosErr.response?.status === 404) {
this.contactResults[i].status = "no-profile";
} else {
this.contactResults[i].status = "error";
this.contactResults[i].error =
axiosErr.response?.data?.error || "Could not check profile";
logger.error(
`Failed to check profile for ${this.contactResults[i].did}:`,
err,
);
}
}
try {
const headers = await getHeaders(this.activeDid);
const embUrl =
`${this.partnerApiServer}/api/partner/userProfileEmbeddingMetadata/` +
encodeURIComponent(this.contactResults[i].did);
const embResponse = await this.axios.get(embUrl, { headers });
const embData = embResponse.data?.data;
if (embData && typeof embData.generateEmbedding === "boolean") {
this.contactResults[i].embeddingMetadata = {
generateEmbedding: embData.generateEmbedding,
isForEmptyString: !!embData.isForEmptyString,
};
} else {
this.contactResults[i].embeddingMetadata = null;
}
} catch (embErr: unknown) {
const axiosErr = embErr as { response?: { status?: number } };
if (axiosErr.response?.status !== 404) {
logger.error(
`Failed to load embedding metadata for ${this.contactResults[i].did}:`,
embErr,
);
}
this.contactResults[i].embeddingMetadata = null;
}
this.contactResults = [...this.contactResults];
}
const didsParam = this.$route.query.dids as string;
if (didsParam) {
sessionStorage.setItem("profileCheckDids", didsParam);
sessionStorage.setItem(
"profileCheckResults",
JSON.stringify(this.contactResults),
);
}
}
async refreshChecks() {
this.isRefreshing = true;
for (let i = 0; i < this.contactResults.length; i++) {
this.contactResults[i].status = "pending";
this.contactResults[i].profileDescription = undefined;
this.contactResults[i].embeddingMetadata = undefined;
this.contactResults[i].error = undefined;
}
this.contactResults = [...this.contactResults];
await this.runChecks();
this.isRefreshing = false;
}
async toggleEmbedding(did: string) {
const idx = this.contactResults.findIndex((r) => r.did === did);
if (idx === -1 || !this.activeDid) return;
const current =
this.contactResults[idx].embeddingMetadata?.generateEmbedding ?? false;
const newValue = !current;
this.embeddingSavingDids = new Set(this.embeddingSavingDids).add(did);
try {
const headers = await getHeaders(this.activeDid);
const url =
`${this.partnerApiServer}/api/partner/userProfileGenerateEmbedding/` +
encodeURIComponent(did);
await this.axios.put(url, { generateEmbedding: newValue }, { headers });
try {
const embUrl =
`${this.partnerApiServer}/api/partner/userProfileEmbeddingMetadata/` +
encodeURIComponent(did);
const embResponse = await this.axios.get(embUrl, { headers });
const embData = embResponse.data?.data;
if (embData && typeof embData.generateEmbedding === "boolean") {
this.contactResults[idx].embeddingMetadata = {
generateEmbedding: embData.generateEmbedding,
isForEmptyString: !!embData.isForEmptyString,
};
}
} catch (refreshErr) {
logger.error(
"Failed to refresh embedding metadata",
!newValue
? "- where a 404 is expected when embedding is turned off:"
: ":",
refreshErr,
);
this.contactResults[idx].embeddingMetadata = {
generateEmbedding: newValue,
isForEmptyString:
this.contactResults[idx].embeddingMetadata?.isForEmptyString ??
true,
};
}
this.contactResults = [...this.contactResults];
this.notify.success(
newValue
? "Embedding generation enabled."
: "Embedding generation disabled.",
TIMEOUTS.STANDARD,
);
} catch (err: unknown) {
const error = (err as { response?: { data?: { error?: string } } })
?.response?.data?.error;
if (error) {
this.notify.error(error, TIMEOUTS.LONG);
} else {
logger.error("Failed to update generate-embedding flag:", err);
this.notify.error(
"Failed to update embedding flag. Try again.",
TIMEOUTS.LONG,
);
}
} finally {
const updated = new Set(this.embeddingSavingDids);
updated.delete(did);
this.embeddingSavingDids = updated;
}
}
goBack() {
this.$router.back();
}
}
</script>

View File

@@ -80,7 +80,7 @@
<!-- Label Filter -->
<div v-if="allLabels.length > 0" class="mt-4 mb-2">
<div class="flex items-center justify-between pl-[16.666%] pr-[16.666%]">
<div class="flex items-center justify-between pl-[10%] pr-[10%]">
<button
class="text-sm font-medium text-blue-600 flex items-center gap-1"
@click="showLabelFilter = !showLabelFilter"
@@ -167,6 +167,23 @@
@copy-selected="copySelectedContacts"
/>
<!-- Check for Profile (admin/organizer feature) -->
<div
v-if="showGeneralAdvanced && contactsSelected.length > 0"
class="my-3 flex justify-center"
>
<button
class="text-sm bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="checkSelectedProfiles"
>
<font-awesome icon="circle-user" class="fa-fw mr-1" />
Check for Public Profile
</button>
<button class="ml-4 text-2xl text-blue-500" @click="showProfileCheckInfo">
<font-awesome icon="circle-info" class="fa-fw" />
</button>
</div>
<GiftedDialog
ref="customGivenDialog"
:initial-giver-entity-type="'person'"
@@ -206,8 +223,11 @@ import LargeIdenticonModal from "../components/LargeIdenticonModal.vue";
import { AppString, NotificationIface } from "../constants/app";
// Legacy logging import removed - using PlatformServiceMixin methods
import { Contact } from "../db/tables/contacts";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
import {
parseContactImportInput,
ContactImportParseResult,
} from "../libs/contactImportPayload";
import {
CONTACT_CSV_HEADER,
createEndorserJwtForDid,
@@ -216,15 +236,8 @@ import {
isDid,
register,
setVisibilityUtil,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
} from "../libs/endorserServer";
import {
GiveSummaryRecord,
UserInfo,
VerifiableCredential,
} from "@/interfaces";
import { GiveSummaryRecord, VerifiableCredential } from "@/interfaces";
import * as libsUtil from "../libs/util";
import { generateSaveAndActivateIdentity } from "../libs/util";
import { logger } from "../utils/logger";
@@ -334,6 +347,7 @@ export default class ContactsView extends Vue {
hideRegisterPromptOnNewContact = false;
isRegistered = false;
showDidCopy = false;
showGeneralAdvanced = false;
showPubKeyCopy = false;
showPubKeyHashCopy = false;
showGiveNumbers = false;
@@ -377,6 +391,7 @@ export default class ContactsView extends Vue {
await this.processContactJwt();
await this.processInviteJwt();
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.showGiveNumbers = !!settings.showContactGivesInline;
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
@@ -397,20 +412,8 @@ export default class ContactsView extends Vue {
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) {
// really should fully verify contents
const { payload } = decodeEndorserJwt(importedContactJwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"], // ".did" is reliable as of v 0.3.49
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
this.$router.push({ path: "/contacts" });
const parsedImport = parseContactImportInput(importedContactJwt);
await this.handleParsedContactImport(parsedImport);
}
}
@@ -598,6 +601,24 @@ export default class ContactsView extends Vue {
this.contactsFiltered = await this.filteredContacts();
}
/**
* Navigate to profile check view with selected contacts
*/
showProfileCheckInfo() {
this.notify.info(
"This User Profile visibility check is a useful report for meeting organizers when running meetings that match people based on common interests.",
TIMEOUTS.VERY_LONG,
);
}
checkSelectedProfiles() {
sessionStorage.setItem("profileCheckFresh", "true");
this.$router.push({
name: "contact-profile-check",
query: { dids: JSON.stringify(this.contactsSelected) },
});
}
get copyButtonClass() {
return this.contactsSelected.length > 0
? "text-md bg-gradient-to-b from-blue-400 to-blue-700 " +
@@ -750,7 +771,7 @@ export default class ContactsView extends Vue {
}
// Try different parsing methods in order
if (await this.tryParseJwtContact(contactInput)) return;
if (await this.tryParseContactImport(contactInput)) return;
if (await this.tryParseCsvContacts(contactInput)) return;
if (await this.tryParseDidContact(contactInput)) return;
if (await this.tryParseJsonContacts(contactInput)) return;
@@ -762,29 +783,51 @@ export default class ContactsView extends Vue {
/**
* Parse contact from JWT URL format
*/
private async tryParseJwtContact(contactInput: string): Promise<boolean> {
private async tryParseContactImport(contactInput: string): Promise<boolean> {
const parsedImport = parseContactImportInput(contactInput);
if (
contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI) ||
contactInput.includes(CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI) ||
contactInput.includes(CONTACT_URL_PATH_ENDORSER_CH_OLD)
parsedImport.kind === "error" &&
parsedImport.code === "not_contact_import_format"
) {
const jwt = getContactJwtFromJwtUrl(contactInput);
if (jwt) {
const { payload } = decodeEndorserJwt(jwt);
const userInfo = payload["own"] as UserInfo;
const newContact = {
did: userInfo.did || payload["iss"],
name: userInfo.name,
nextPubKeyHashB64: userInfo.nextPublicEncKeyHash,
profileImageUrl: userInfo.profileImageUrl,
publicKeyBase64: userInfo.publicEncKey,
registered: userInfo.registered,
} as Contact;
await this.addContact(newContact);
return false;
}
await this.handleParsedContactImport(parsedImport);
return true;
}
private async handleParsedContactImport(
parsedImport: ContactImportParseResult,
): Promise<void> {
if (parsedImport.kind === "error") {
this.notify.error(parsedImport.message, TIMEOUTS.LONG);
return;
}
return false;
if (parsedImport.kind === "single") {
await this.addOrEditImportedContact(parsedImport.contact);
return;
}
if (parsedImport.contacts.length === 1) {
await this.addOrEditImportedContact(parsedImport.contacts[0]);
return;
}
await this.$router.push({
name: "contact-import",
params: { jwt: parsedImport.jwt },
});
}
private async addOrEditImportedContact(contact: Contact): Promise<void> {
const existingContact = await this.$getContact(contact.did);
if (!existingContact) {
await this.addContact(contact);
}
await this.$router.push({
name: "contact-edit",
params: { did: contact.did },
});
}
/**

View File

@@ -136,14 +136,50 @@
/>
</span>
</div>
<button class="ml-2 mr-2 mt-4" @click="toggleUserProfile">
User Profile
<font-awesome
v-if="showUserProfile"
icon="chevron-down"
class="text-blue-400"
/>
<font-awesome v-else icon="chevron-right" class="text-blue-400" />
</button>
<div
v-if="showUserProfile"
class="mt-2 px-4 py-3 bg-slate-100 rounded-md"
>
<div v-if="userProfileLoading" class="text-sm text-slate-600">
<font-awesome icon="spinner" class="fa-spin-pulse mr-2" />
Loading
</div>
<div v-else-if="userProfileError" class="text-sm text-slate-600">
{{ userProfileError }}
</div>
<div v-else-if="userProfileData" class="text-sm">
<p
v-if="userProfileData.description"
class="text-slate-700 whitespace-pre-wrap"
>
{{ userProfileData.description }}
</p>
<p v-else class="text-slate-500 italic">
This person has no profile description or it's not visible to you.
</p>
</div>
</div>
<div class="flex justify-between mt-4">
<div class="flex items-center">
<div v-if="activeDid" class="flex justify-between">
<div>
<div v-if="activeDid" class="flex justify-between items-end">
<div class="flex items-end gap-1">
<div
v-if="contactFromDid?.did !== activeDid"
class="flex flex-col items-center"
>
<button
v-if="
contactFromDid?.seesMe && contactFromDid.did !== activeDid
"
v-if="contactFromDid?.seesMe"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They can see your activity"
@click="confirmSetVisibility(contactFromDid, false)"
@@ -152,9 +188,7 @@
<font-awesome icon="eye" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
"
v-else-if="!contactFromDid?.seesMe"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They cannot see your activity"
@click="confirmSetVisibility(contactFromDid, true)"
@@ -162,12 +196,15 @@
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
</button>
<span class="text-xs text-slate-600">See You</span>
</div>
<div
v-if="contactFromDid?.did !== activeDid"
class="flex flex-col items-center"
>
<button
v-if="
contactFromDid?.iViewContent &&
contactFromDid.did !== activeDid
"
v-if="!contactFromDid?.hideTheirContent"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You watch their activity"
@click="confirmViewContent(contactFromDid, false)"
@@ -176,10 +213,7 @@
<font-awesome icon="eye" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.iViewContent &&
contactFromDid?.did !== activeDid
"
v-else-if="contactFromDid?.hideTheirContent"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You do not watch their activity"
@click="confirmViewContent(contactFromDid, true)"
@@ -187,20 +221,30 @@
<font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
</button>
<span class="text-xs text-slate-600">Watch Them</span>
</div>
<button
<div
v-if="contactFromDid?.did !== activeDid"
class="flex flex-col items-center"
>
<button
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Check Visibility"
@click="checkVisibility(contactFromDid)"
>
<font-awesome icon="rotate" class="fa-fw" />
</button>
<span class="text-xs text-slate-600">Check</span>
</div>
</div>
<button
<div
v-if="contactFromDid?.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
class="flex flex-col items-center ml-6"
>
<button
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Registration"
@click="confirmRegister(contactFromDid)"
>
@@ -215,16 +259,25 @@
class="fa-fw"
/>
</button>
<span class="text-xs text-slate-600">Register</span>
</div>
</div>
<div
v-if="contactFromDid?.did !== activeDid"
class="flex flex-col items-center ml-6"
>
<button
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Delete"
@click="confirmDeleteContact(contactFromDid)"
>
<font-awesome icon="trash-can" class="fa-fw" />
</button>
<span class="text-xs text-slate-600">Delete</span>
</div>
</div>
<div v-if="!contactFromDid?.profileImageUrl">
<div>Auto-Generated Icon</div>
<div class="flex justify-center">
@@ -253,6 +306,57 @@
/>
</div>
</div>
<!-- Only admins can set the generate-embedding flag -->
<div
v-if="showGeneralAdvanced && viewingDid"
class="mt-2 pt-2"
data-testid="generateEmbeddingSection"
>
<div class="flex items-center gap-2">
<label class="block text-sm font-medium text-gray-700 mb-2 mt-2">
Always generate embedding
</label>
<button
type="button"
role="switch"
:aria-checked="embeddingMetadata?.generateEmbedding ?? false"
:disabled="embeddingMetadataSaving || embeddingMetadataLoading"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
:class="
(embeddingMetadata?.generateEmbedding ?? false)
? 'bg-blue-600'
: 'bg-gray-200'
"
@click="toggleGenerateEmbedding"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition"
:class="
(embeddingMetadata?.generateEmbedding ?? false)
? 'translate-x-5'
: 'translate-x-1'
"
/>
</button>
<span class="text-sm text-gray-600">
{{ (embeddingMetadata?.generateEmbedding ?? false) ? "On" : "Off" }}
<span v-if="embeddingMetadataLoading" class="ml-1">(loading…)</span>
<span v-else-if="embeddingMetadataSaving" class="ml-1"
>(saving…)</span
>
</span>
<span class="text-sm text-gray-600">
{{
embeddingMetadata?.isForEmptyString == null
? ""
: embeddingMetadata?.isForEmptyString
? "- Embedding is for blank description"
: "- Embedding is for non-blank description"
}}
</span>
</div>
</div>
</div>
<div v-else class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<!-- !contactFromDid -->
@@ -332,7 +436,10 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue";
import TopMessage from "../components/TopMessage.vue";
import { NotificationIface } from "../constants/app";
import {
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { BoundingBox } from "../db/tables/settings";
@@ -367,6 +474,7 @@ import {
} from "@/constants/notifications";
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
import { getContactMethodLabel } from "@/constants/contacts";
import type { UserProfile } from "@/libs/partnerServer";
/**
* DIDView Component
@@ -406,13 +514,25 @@ export default class DIDView extends Vue {
contactLabels: string[] = [];
contactYaml = "";
embeddingMetadata: {
generateEmbedding: boolean;
isForEmptyString: boolean;
} | null = null;
embeddingMetadataLoading = false;
embeddingMetadataSaving = false;
hitEnd = false;
isLoading = false;
isMyDid = false;
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
searchBox: { name: string; bbox: BoundingBox } | null = null;
showDidDetails = false;
showGeneralAdvanced = false;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
showUserProfile = false;
userProfileData: UserProfile | null = null;
userProfileError: string | null = null;
userProfileLoading = false;
viewingDid?: string;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
@@ -444,6 +564,9 @@ export default class DIDView extends Vue {
await this.loadContactInformation();
await this.loadClaimsAbout();
await this.checkIfOwnDID();
if (this.showGeneralAdvanced && this.activeDid) {
await this.loadUserProfileEmbeddingMetadata();
}
}
}
@@ -459,6 +582,9 @@ export default class DIDView extends Vue {
this.activeDid = activeIdentity.activeDid || "";
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.partnerApiServer =
(settings.partnerApiServer as string) || DEFAULT_PARTNER_API_SERVER;
}
/**
@@ -519,6 +645,48 @@ export default class DIDView extends Vue {
this.isMyDid = allAccountDids.includes(this.viewingDid);
}
/**
* Toggles the "always generate embedding" flag for the viewed DID on the partner API.
* Only permissioned (admin) users can change this; API returns 403 otherwise.
*/
async toggleGenerateEmbedding() {
if (!this.viewingDid || !this.activeDid) {
return;
}
const currentValue = this.embeddingMetadata?.generateEmbedding ?? false;
const newValue = !currentValue;
this.embeddingMetadataSaving = true;
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.partnerApiServer}/api/partner/userProfileGenerateEmbedding/${encodeURIComponent(this.viewingDid)}`;
await this.axios.put(url, { generateEmbedding: newValue }, { headers });
// Refresh embedding metadata from the dedicated endpoint
await this.loadUserProfileEmbeddingMetadata();
this.notify.success(
newValue
? "Contact tagged to always generate embedding."
: "Contact untagged from always generating embedding.",
TIMEOUTS.STANDARD,
);
} catch (err: unknown) {
const error = (err as { response?: { data?: { error?: string } } })
?.response?.data?.error;
if (error) {
this.notify.error(error, TIMEOUTS.LONG);
} else {
logger.error("Failed to update generate-embedding flag:", err);
this.notify.error(
"Failed to update generate-embedding flag. Try again.",
TIMEOUTS.LONG,
);
}
} finally {
this.embeddingMetadataSaving = false;
}
}
/**
* Loads additional claims when user scrolls to bottom
* Used by infinite scroll component to implement pagination
@@ -545,6 +713,83 @@ export default class DIDView extends Vue {
this.showDidDetails = !this.showDidDetails;
}
/**
* Toggles the User Profile section and loads profile from server on first expand.
*/
toggleUserProfile() {
this.showUserProfile = !this.showUserProfile;
if (this.showUserProfile && !this.userProfileData) {
this.loadUserProfile();
}
}
/**
* Loads embedding metadata (generateEmbedding, isForEmptyString) for the viewing DID
* from the partner API userProfileEmbeddingMetadata endpoint.
*/
async loadUserProfileEmbeddingMetadata() {
if (!this.viewingDid || !this.activeDid) return;
this.embeddingMetadataLoading = true;
this.embeddingMetadata = null;
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.partnerApiServer}/api/partner/userProfileEmbeddingMetadata/${encodeURIComponent(this.viewingDid)}`;
const response = await this.axios.get(url, { headers });
const data = response.data?.data;
if (data && typeof data.generateEmbedding === "boolean") {
this.embeddingMetadata = {
generateEmbedding: data.generateEmbedding,
isForEmptyString: !!data.isForEmptyString,
};
} else {
this.embeddingMetadata = null;
}
} catch (err: unknown) {
const axiosErr = err as { response?: { status?: number } };
if (axiosErr.response?.status === 404) {
this.embeddingMetadata = null;
} else {
logger.error("Failed to load user profile embedding metadata:", err);
}
} finally {
this.embeddingMetadataLoading = false;
}
}
/**
* Loads the user profile for the viewing DID from the partner API.
* Shows profile content or a message if the DID has no profile or it is not visible.
*/
async loadUserProfile() {
if (!this.viewingDid || !this.activeDid) return;
this.userProfileLoading = true;
this.userProfileError = null;
this.userProfileData = null;
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.partnerApiServer}/api/partner/userProfileForIssuer/${encodeURIComponent(this.viewingDid)}`;
const response = await this.axios.get(url, { headers });
const data = response.data?.data;
if (data) {
this.userProfileData = data;
}
} catch (err: unknown) {
const axiosErr = err as {
response?: { status?: number; data?: { error?: string } };
};
const status = axiosErr.response?.status;
const message = axiosErr.response?.data?.error;
if (status === 404 && message) {
this.userProfileError = message;
} else {
this.userProfileError =
"This DID does not have a profile or doesn't make it visible to you.";
}
} finally {
this.userProfileLoading = false;
}
}
showLargeProfileImage() {
this.showLargeIdenticonUrl = this.contactFromDid?.profileImageUrl;
}
@@ -897,7 +1142,7 @@ export default class DIDView extends Vue {
this.notify.confirm(contentVisibilityPrompt, async () => {
const success = await this.setViewContent(contact, view);
if (success) {
contact.iViewContent = view; // see visibility note about not working inside setVisibility
contact.hideTheirContent = !view; // inverted: view=true -> hideTheirContent=false
}
});
}
@@ -910,7 +1155,7 @@ export default class DIDView extends Vue {
* @returns Boolean indicating success
*/
async setViewContent(contact: Contact, visibility: boolean) {
await this.$updateContact(contact.did, { iViewContent: visibility });
await this.$updateContact(contact.did, { hideTheirContent: !visibility });
const message =
"You will" +
(visibility ? "" : " not") +

View File

@@ -59,6 +59,7 @@ import {
} from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db/databaseUtil";
import { logger } from "../utils/logger";
import { SUPPORT_EMAIL } from "../constants/app";
const route = useRoute();
const router = useRouter();
@@ -105,7 +106,7 @@ const goHome = () => router.replace({ name: "home" });
const reportIssue = () => {
// Open a support form or email
window.open(
"mailto:support@timesafari.app?subject=Invalid Deep Link&body=" +
`mailto:${SUPPORT_EMAIL}?subject=Invalid Deep Link&body=` +
encodeURIComponent(
`I encountered an error with a deep link: timesafari://${originalPath.value}\nError: ${errorMessage.value}`,
),

View File

@@ -5,7 +5,7 @@
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Redirecting to Time Safari
Redirecting to app
</h1>
</div>
@@ -15,11 +15,11 @@
<p v-if="isMobile">
{{
isIOS
? "Opening Time Safari app on your iPhone..."
: "Opening Time Safari app on your Android device..."
? "Opening on your iPhone..."
: "Opening on your Android device..."
}}
</p>
<p v-else>Opening Time Safari app...</p>
<p v-else>Opening the app...</p>
<p class="text-sm mt-2">
<span v-if="isMobile"
>If the app doesn't open automatically, use one of these
@@ -36,8 +36,8 @@
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
@click="handleDeepLinkClick"
>
<span v-if="isMobile">Open in Time Safari App</span>
<span v-else>Try Opening in Time Safari App</span>
<span v-if="isMobile">Open in App</span>
<span v-else>Try Opening in App</span>
</a>
</div>
@@ -61,8 +61,7 @@
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
</p>
<p v-else>
If you have the Time Safari app installed, you can also copy this
link:
If you have the app installed, you can also copy this link:
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
</p>
</div>
@@ -177,13 +176,13 @@ export default class DeepLinkRedirectView extends Vue {
"Fallback deep link failed: " + errorStringForLog(error),
);
this.pageError =
"Redirecting to the Time Safari app failed. Please use a manual option below.";
"Redirecting to the app failed. Please use a manual option below.";
}
}, 100);
} catch (error) {
logger.error("Deep link redirect failed: " + errorStringForLog(error));
this.pageError =
"Unable to open the Time Safari app. Please use a manual option below.";
"Unable to open the app. Please use a manual option below.";
}
}

View File

@@ -31,10 +31,10 @@
<div class="truncate">
From
{{
givenByProjectFunction()
givenByProject()
? providerProjectName
: // check for DID because name could be "Unnamed"
givenByPersonFunction() && giverDid
givenByPerson() && giverDid
? giverName
: "someone not named"
}}
@@ -42,9 +42,10 @@
<div class="truncate">
to
{{
givenToProject
givenToProject()
? fulfillsProjectName
: givenToRecipient
: // check for DID because name could be "Unnamed"
givenToPerson() && recipientDid
? recipientName
: "someone not named"
}}
@@ -114,9 +115,9 @@
<div class="flex items-center">
<label class="text-sm flex-1">
{{
givenByProjectFunction() && providerProjectName
givenByProject() && providerProjectName
? "From " + providerProjectName
: givenByPersonFunction() && giverName
: givenByPerson() && giverName
? "From " + giverName
: "Unnamed giver"
}}
@@ -133,15 +134,9 @@
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:conflict-checker="wouldCreateConflict"
:from-project-id="providerProjectId"
:to-project-id="fulfillsProjectId"
:conflict-checker="wouldCreateConflictWithRecipient"
:giver="currentGiver"
:receiver="currentReceiver"
:description="description"
:amount-input="amountInput"
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
@entity-selected="handleGiverEntitySelected"
@cancel="closeGiverSelection"
@@ -169,9 +164,9 @@
<div class="flex items-center">
<label class="text-sm flex-1">
{{
givenToProjectFunction() && fulfillsProjectName
givenToProject() && fulfillsProjectName
? "To " + fulfillsProjectName
: givenToPersonFunction() && recipientName
: givenToPerson() && recipientName
? "To " + recipientName
: "Unnamed recipient"
}}
@@ -188,15 +183,9 @@
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:conflict-checker="wouldCreateConflict"
:from-project-id="providerProjectId"
:to-project-id="fulfillsProjectId"
:conflict-checker="wouldCreateConflictWithGiver"
:giver="currentGiver"
:receiver="currentReceiver"
:description="description"
:amount-input="amountInput"
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
@entity-selected="handleRecipientEntitySelected"
@cancel="closeRecipientSelection"
@@ -309,8 +298,6 @@ export default class GiftedDetails extends Vue {
destinationPathAfter = "";
fulfillsProjectId = "";
fulfillsProjectName = "a project";
givenToProject = false; // basically static, based on input; if we allow changing then let's fix things (see below)
givenToRecipient = false; // basically static, based on input; if we allow changing then let's fix things (see below)
giverDid = "";
giverName = "";
hideBackButton = false;
@@ -414,7 +401,6 @@ export default class GiftedDetails extends Vue {
this.imageUrl = ((this.$route.query["imageUrl"] as string) ||
this.prevCredToEdit?.claim?.image ||
localStorage.getItem("imageUrl") ||
this.imageUrl) as string;
// this is an endpoint for sharing project info to highlight something given
@@ -441,10 +427,8 @@ export default class GiftedDetails extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
const dbContacts = await this.$dbQuery("SELECT * FROM contacts");
this.allContacts = this.$mapQueryResultToValues(
dbContacts,
) as unknown as Contact[];
this.allContacts = await this.$contactsByDateAdded();
this.allMyDids = await retrieveAccountDids();
if (
@@ -468,9 +452,6 @@ export default class GiftedDetails extends Vue {
);
}
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.givenToProject = !!this.fulfillsProjectId;
this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
@@ -498,19 +479,19 @@ export default class GiftedDetails extends Vue {
}
}
givenByPersonFunction() {
givenByPerson() {
return !!this.giverDid;
}
givenByProjectFunction() {
givenByProject() {
return !!this.providerProjectId;
}
givenToPersonFunction() {
givenToPerson() {
return !!this.recipientDid;
}
givenToProjectFunction() {
givenToProject() {
return !!this.fulfillsProjectId;
}
@@ -595,7 +576,6 @@ export default class GiftedDetails extends Vue {
return;
}
localStorage.removeItem("imageUrl");
this.imageUrl = "";
} catch (error) {
logger.error("Error deleting image:", error);
@@ -603,7 +583,6 @@ export default class GiftedDetails extends Vue {
if ((error as any)?.response?.status === 404) {
logger.log("Weird: the image was already deleted.", error);
localStorage.removeItem("imageUrl");
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
@@ -660,7 +639,7 @@ export default class GiftedDetails extends Vue {
TIMEOUTS.SHORT,
);
} else {
// must be because givenToProject is true
// must be because givenToProject() is true
this.notify.warning(
"You cannot assign both to a recipient and to a project.",
TIMEOUTS.SHORT,
@@ -767,7 +746,6 @@ export default class GiftedDetails extends Vue {
logger.error("Error checking seed backup status:", error);
}
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
@@ -837,12 +815,12 @@ export default class GiftedDetails extends Vue {
* Computed property for current receiver entity data
*/
get currentReceiver() {
if (this.givenToProject && this.fulfillsProjectId) {
if (this.givenToProject() && this.fulfillsProjectId) {
return {
handleId: this.fulfillsProjectId,
name: this.fulfillsProjectName,
};
} else if (this.givenToRecipient && this.recipientDid) {
} else if (this.givenToPerson() && this.recipientDid) {
return {
did: this.recipientDid,
name: this.recipientName,
@@ -930,7 +908,7 @@ export default class GiftedDetails extends Vue {
/**
* Check if selecting an entity would create a conflict
*/
wouldCreateConflict(identifier: string): boolean {
wouldCreateConflictWithGiver(identifier: string): boolean {
// Check if it would conflict with giver
if (this.giverDid === identifier) {
return true;
@@ -938,6 +916,9 @@ export default class GiftedDetails extends Vue {
if (this.providerProjectId === identifier) {
return true;
}
return false;
}
wouldCreateConflictWithRecipient(identifier: string): boolean {
// Check if it would conflict with recipient
if (this.recipientDid === identifier) {
return true;

View File

@@ -6,7 +6,7 @@
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Time Safari Onboarding Instructions
Onboarding Instructions
</h1>
</div>
@@ -80,27 +80,6 @@
4) Add yourself to their Contacts <font-awesome icon="users" />
</p>
</div>
<h1 class="font-bold text-xl">Install</h1>
<div>
<p>
Have them visit TimeSafari.app in a browser, preferably Chrome or Safari,
and then look for the "Install" selection which adds this app to their desktop.
This enables other things, like the ability to "share" a photo from their
device directly to Time Safari, and it makes notifications more reliable.
</p>
</div>
<h1 class="font-bold text-xl">Enable Notifications</h1>
<div>
<p>
Enable notifications from the Account page <font-awesome icon="circle-user" />.
Those notifications might show up on the device depending on your settings.
For the most reliable habits, set an alarm or do some other ritual to record gratitude
every day.
</p>
</div>
</div>
<!-- eslint enable -->
</section>

View File

@@ -442,9 +442,9 @@
browser window and look at the version there.
</li>
<li>
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.
</li>
<li>
There may be a problem with your identity. Go to the Identity
@@ -467,7 +467,7 @@
<a href="https://duckduckgo.com/?q=unregister+service+worker" target="_blank" class="text-blue-500">Search</a>
for instructions for other browsers.</li>
</ul>
Then reload Time Safari.
Then reload the page.
</li>
</ul>
<p>
@@ -552,8 +552,8 @@
</h2>
<p>
Contact us at
<a href="mailto:info@TimeSafari.app" class="text-blue-500"
>info@TimeSafari.app</a
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500"
>{{ SUPPORT_EMAIL }}</a
>
</p>
@@ -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

View File

@@ -167,18 +167,12 @@ Raymer * @version 1.0.0 */
<div class="flex gap-2 items-center mb-3">
<h2 class="font-bold">Latest Activity</h2>
<button
v-if="resultsAreFiltered()"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openFeedFilters()"
>
<font-awesome
icon="filter"
class="block text-center w-[1em] translate-y-[0.05em]"
/>
</button>
<button
v-else
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
class="block ms-auto text-sm text-center text-white p-1.5 rounded-full shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)]"
:class="
isAnyFeedFilterOn
? 'bg-gradient-to-b from-blue-400 to-blue-700'
: 'bg-gradient-to-b from-slate-400 to-slate-700'
"
@click="openFeedFilters()"
>
<font-awesome
@@ -779,7 +773,7 @@ export default class HomeView extends Vue {
private async loadContacts() {
this.allContacts = await this.$contacts();
this.blockedContactDids = this.allContacts
.filter((c) => !c.iViewContent)
.filter((c) => c.hideTheirContent)
.map((c) => c.did);
}
@@ -971,17 +965,6 @@ export default class HomeView extends Vue {
);
}
/**
* Checks if feed results are being filtered
*
* @public
* Used in template for filter button display
* @returns true if visible or nearby filters are active
*/
resultsAreFiltered() {
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
}
/**
* Checks if browser notifications are supported
*
@@ -1003,12 +986,12 @@ export default class HomeView extends Vue {
* Called by FeedFilters component when filters change
*/
async reloadFeedOnChange() {
logger.debug("[HomeView] 🔄 reloadFeedOnChange() called - refreshing feed");
logger.debug("[HomeView] reloadFeedOnChange() called - refreshing feed");
// Get current settings without overriding with defaults
const settings = await this.$accountSettings(this.activeDid);
logger.debug("[HomeView] 📊 Current filter settings:", {
logger.debug("[HomeView] Current filter settings:", {
filterFeedByVisible: settings.filterFeedByVisible,
filterFeedByNearby: settings.filterFeedByNearby,
searchBoxes: settings.searchBoxes?.length || 0,
@@ -1018,7 +1001,7 @@ export default class HomeView extends Vue {
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
logger.debug("[HomeView] 🎯 Updated filter states:", {
logger.debug("[HomeView] Updated filter states:", {
isFeedFilteredByVisible: this.isFeedFilteredByVisible,
isFeedFilteredByNearby: this.isFeedFilteredByNearby,
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
@@ -1052,17 +1035,9 @@ export default class HomeView extends Vue {
/**
* Checks if coordinates fall within any search box
*
* @internal
* @callGraph
* Called by: shouldIncludeRecord()
* Calls: None
*
* @chain
* shouldIncludeRecord() -> latLongInAnySearchBox()
*
* @requires
* - this.searchBoxes
*
* @param lat Latitude to check
* @param long Longitude to check
* @returns true if coordinates are within any search box
@@ -1087,29 +1062,11 @@ export default class HomeView extends Vue {
* - Updates last viewed claim ID
* - Handles paging if needed
*
* @internal
* @callGraph
* Called by: loadFeedData(), manual refresh
* Calls:
* - retrieveGives()
* - processFeedResults()
* - updateFeedLastViewedId()
* - handleFeedError()
*
* @chain
* loadFeedData() -> updateAllFeed() -> retrieveGives()
*
* @requires
* - this.apiServer
* - this.feedPreviousOldestId
*
* @modifies
* - this.isFeedLoading
* - this.feedData (via processFeedResults)
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
*/
async updateAllFeed() {
logger.debug("[HomeView] 🚀 updateAllFeed() called", {
logger.debug("[HomeView] updateAllFeed() called", {
isFeedLoading: this.isFeedLoading,
currentFeedDataLength: this.feedData.length,
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
@@ -1126,7 +1083,7 @@ export default class HomeView extends Vue {
this.feedPreviousOldestId,
);
logger.debug("[HomeView] 📡 Retrieved gives from API", {
logger.debug("[HomeView] Retrieved gives from API", {
resultsCount: results.data.length,
endOfResults,
});
@@ -1137,7 +1094,7 @@ export default class HomeView extends Vue {
await this.processFeedResults(results.data);
await this.updateFeedLastViewedId(results.data);
logger.debug("[HomeView] 📝 Processed feed results", {
logger.debug("[HomeView] Processed feed results", {
processedCount: this.feedData.length,
});
}
@@ -1161,26 +1118,13 @@ export default class HomeView extends Vue {
/**
* Processes feed results and adds them to feedData
*
* @internal
* @callGraph
* Called by: updateAllFeed()
* Calls: processRecord()
*
* @chain
* updateAllFeed() -> processFeedResults()
*
* @requires
* - this.feedData
* - this.feedPreviousOldestId
*
* @modifies
* - this.feedData
* - this.feedPreviousOldestId
*
* @param records Array of feed records to process
*/
private async processFeedResults(records: GiveSummaryRecord[]) {
logger.debug("[HomeView] 📝 Processing feed results:", {
logger.debug("[HomeView] Processing feed results:", {
inputRecords: records.length,
currentFilters: {
isAnyFeedFilterOn: this.isAnyFeedFilterOn,
@@ -1202,7 +1146,7 @@ export default class HomeView extends Vue {
}
}
logger.debug("[HomeView] 📊 Feed processing results:", {
logger.debug("[HomeView] Feed processing results:", {
processed: processedCount,
filtered: filteredCount,
total: records.length,
@@ -1215,32 +1159,9 @@ export default class HomeView extends Vue {
/**
* Processes a single record and returns it if it passes filters
*
* @internal
* @callGraph
* Called by: processFeedResults()
* Calls:
* - extractClaim()
* - extractGiverDid()
* - extractRecipientDid()
* - getFulfillsPlan()
* - shouldIncludeRecord()
* - extractProvider()
* - getProvidedByPlan()
* - createFeedRecord()
*
* @chain
* updateAllFeed() -> processFeedResults() -> processRecord()
*
* @requires
* - this.isAnyFeedFilterOn
* - this.isFeedFilteredByVisible
* - this.isFeedFilteredByNearby
* - this.activeDid
* - this.allContacts
*
* @modifies
* - this.feedData (via createFeedRecord)
*
* @param record The record to process
* @returns Processed record if it passes filters, null otherwise
*/
@@ -1254,7 +1175,7 @@ export default class HomeView extends Vue {
const fulfillsPlan = await this.getFulfillsPlan(record);
// Log record details for debugging
logger.debug("[HomeView] 🔍 Processing record:", {
logger.debug("[HomeView] Processing record:", {
recordId: record.jwtId,
hasFulfillsPlan: !!fulfillsPlan,
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
@@ -1266,14 +1187,12 @@ export default class HomeView extends Vue {
});
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
logger.debug("[HomeView] ❌ Record filtered out:", record.jwtId);
return null;
}
const provider = this.extractProvider(claim);
const providedByPlan = await this.getProvidedByPlan(provider);
logger.debug("[HomeView] ✅ Record included:", record.jwtId);
return this.createFeedRecord(
record,
claim,
@@ -1397,6 +1316,11 @@ export default class HomeView extends Vue {
fulfillsPlan?: FulfillsPlan,
): boolean {
if (this.blockedContactDids.includes(record.issuerDid)) {
logger.debug("[HomeView] Record filtered (blocked contact):", {
recordId: record.jwtId,
issuerDid: record.issuerDid,
hint: "This issuer is in your contacts with 'don't watch their activity'. Visit their DID page and tap the eye button to show their content.",
});
return false;
}
@@ -1423,9 +1347,10 @@ export default class HomeView extends Vue {
}
}
// Add debug logging for nearby filter
// Add debug logging for any other filter that causes hiding
if (this.isFeedFilteredByNearby && record.fulfillsPlanHandleId) {
logger.debug("[HomeView] 🔍 Nearby filter check:", {
if (!anyMatch) {
logger.debug("[HomeView] Nearby filter check:", {
recordId: record.jwtId,
hasFulfillsPlan: !!fulfillsPlan,
hasLocation: !!(fulfillsPlan?.locLat && fulfillsPlan?.locLon),
@@ -1442,6 +1367,7 @@ export default class HomeView extends Vue {
finalResult: anyMatch,
});
}
}
return anyMatch;
}

View File

@@ -699,7 +699,6 @@ export default class OfferDetailsView extends Vue {
NOTIFY_OFFER_SUCCESS_RECORDED.message,
TIMEOUTS.LONG,
);
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {

View File

@@ -131,6 +131,7 @@ import {
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NotificationIface } from "@/constants/app";
import { AxiosErrorResponse } from "@/interfaces";
interface Meeting {
name: string;
@@ -209,11 +210,6 @@ export default class OnboardMeetingListView extends Vue {
* 3. If not attending: Fetch all available meetings (groupsOnboarding endpoint)
* 4. Handle loading states and error conditions
*
* API Endpoints Used:
* - GET /api/partner/groupOnboardMember - Check current attendance
* - GET /api/partner/groupOnboard/{id} - Get meeting details
* - GET /api/partner/groupsOnboarding - Get all available meetings
*
* State Management:
* - Sets isLoading flag during API calls
* - Updates attendingMeeting or meetings array
@@ -271,7 +267,8 @@ export default class OnboardMeetingListView extends Vue {
true,
);
this.notify.error(
serverMessageForUser(error) || "There was a problem fetching meetings.",
serverMessageForUser(error as unknown as AxiosErrorResponse) ||
"There was a problem fetching meetings.",
TIMEOUTS.LONG,
);
} finally {

View File

@@ -40,8 +40,10 @@
</div>
</div>
<!-- Members List -->
<MembersList v-else :password="password" @error="handleError" />
<div v-else>
<!-- Any Match + Members List -->
<MeetingMembersList :password="password" @error="handleError" />
</div>
<!-- Project Link Section -->
<div v-if="projectLink" class="mt-8 p-4 border rounded-lg bg-white shadow">
@@ -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),

View File

@@ -7,7 +7,7 @@
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Onboarding Meeting
Onboarding Meeting Admin
</h1>
<!-- Spacer (no Back button) -->
@@ -63,8 +63,8 @@
<div v-if="currentMeeting.password" class="mt-4">
<p class="text-gray-600">
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.
</p>
</div>
<div v-else class="text-red-600">
@@ -307,15 +307,251 @@
</li>
</ul>
<MembersList
<MeetingMembersList
ref="membersList"
:match-pairs="matchPairs"
:password="currentMeeting.password || ''"
:show-organizer-tools="true"
:excluded-dids="excludedDids"
:exclusion-locked="hasActiveMatches"
class="mt-4"
@error="handleMembersError"
@toggle-exclusion="toggleExclusion"
@members-loaded="refreshAdmittedMembers"
/>
</div>
<div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<!-- Pairwise matches (organizer only: this page is organizer's meeting) -->
<div class="border-gray-200">
<h3 class="font-semibold mb-2">Matching Pairs</h3>
<div class="flex flex-wrap gap-2 mb-4">
<button
type="button"
class="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isPostingMatch"
@click="promptPreMatchConfirm()"
>
<font-awesome
v-if="isPostingMatch"
icon="spinner"
class="fa-spin fa-fw"
/>
Make New Matches
</button>
<button
type="button"
:class="[
'px-3 py-2 text-sm rounded',
isPostingMatch || !matchPairs?.length
? 'bg-red-400 text-white opacity-50 cursor-not-allowed'
: 'bg-red-600 text-white hover:bg-red-700',
]"
@click="handleEraseClick()"
>
<font-awesome
v-if="isPostingMatch"
icon="spinner"
class="fa-spin fa-fw"
/>
Erase to Start Over
</button>
</div>
<!-- Do Not Pair Groups -->
<div class="mb-4 pt-2 border-t border-gray-100">
<h4 class="text-sm font-medium text-gray-700 mb-2">
Do Not Pair Together
</h4>
<p class="text-xs text-gray-500 mb-2">
People in the same group will not be matched with each other.
</p>
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
Erase matches to change restrictions.
</p>
<MeetingExclusionGroups
:groups="doNotPairGroups"
:available-members="admittedMembers"
:disabled="hasActiveMatches"
@update="handleDoNotPairGroupsUpdate"
/>
</div>
<div v-if="isLoadingMatches" class="text-sm text-gray-500 py-2">
<font-awesome icon="spinner" class="fa-spin fa-fw" />
Loading matches…
</div>
<ul
v-else-if="matchPairs && matchPairs.length > 0"
class="list-none space-y-3 text-sm"
>
<li
v-for="pair in matchPairs"
:key="pair.pairNumber"
class="p-3 rounded border border-gray-200 bg-gray-50"
>
<span class="font-medium">Pair {{ pair.pairNumber }}</span>
<span class="text-gray-600">
(similarity {{ pair.similarity.toFixed(2) }})</span
>
<ul class="mt-2 ml-2 space-y-1">
<li
v-for="p in pair.participants"
:key="p.issuerDid"
class="text-gray-700"
>
{{
p.decryptedContentObject?.name
? p.decryptedContentObject.name
: "(No Name)"
}}
-
{{
p.description
? p.description.substring(0, 80) +
(p.description.length > 80 ? "…" : "")
: "(No Profile)"
}}
</li>
</ul>
</li>
</ul>
<p
v-else-if="matchPairs && matchPairs.length === 0"
class="text-sm text-gray-500 py-2"
>
No matches yet. Click "Make New Matches" to pair members by profile
similarity.
</p>
<div
v-if="unmatchedMembers.length > 0"
class="mt-3 p-3 rounded border border-amber-200 bg-amber-50 text-sm"
>
<h4 class="font-medium text-amber-800 mb-1">
Not Paired ({{ unmatchedMembers.length }})
</h4>
<ul class="space-y-0.5">
<li
v-for="m in unmatchedMembers"
:key="m.did"
class="text-amber-700"
>
{{ m.name }} —
<span class="text-amber-600 italic">{{ m.reason }}</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Pre-Match Confirmation Dialog -->
<div
v-if="showPreMatchConfirm"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-white rounded-lg p-6 max-w-md w-full max-h-[80vh] overflow-y-auto"
>
<h3 class="text-lg font-medium mb-4">Confirm Matching</h3>
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-1">
Will be matched ({{ includedMembers.length }})
</h4>
<div class="flex flex-wrap gap-1">
<span
v-for="member in includedMembers"
:key="member.did"
class="inline-block px-2 py-0.5 bg-green-100 text-green-800 rounded-full text-xs"
>
{{ member.name }}
</span>
<span
v-if="includedMembers.length === 0"
class="text-xs text-gray-400 italic"
>
No participants
</span>
</div>
</div>
<div v-if="excludedDids.length > 0" class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-1">
Excluded ({{ excludedDids.length }})
</h4>
<div class="flex flex-wrap gap-1">
<span
v-for="did in excludedDids"
:key="did"
class="inline-block px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs line-through"
>
{{ getMemberNameByDid(did) }}
</span>
</div>
</div>
<div
v-if="doNotPairGroups.some((g) => g.memberDids.length >= 2)"
class="mb-4"
>
<h4 class="text-sm font-medium text-gray-700 mb-1">
Do Not Pair Groups
</h4>
<div class="space-y-1">
<div
v-for="group in doNotPairGroups.filter(
(g) => g.memberDids.length >= 2,
)"
:key="group.id"
class="text-xs text-gray-600"
>
<span class="font-medium">{{
group.name || "Unnamed group"
}}</span
>:
{{
group.memberDids.map((d) => getMemberNameByDid(d)).join(", ")
}}
</div>
</div>
</div>
<div
v-if="previousMatchedPairs.length > 0"
class="mb-4 text-xs text-gray-500"
>
{{ previousMatchedPairs.length }} previous pair(s) will be avoided.
</div>
<div
v-if="includedMembers.length % 2 !== 0"
class="mb-4 p-2 rounded border border-amber-300 bg-amber-50 text-xs text-amber-800"
>
<font-awesome icon="triangle-exclamation" class="mr-1" />
Odd number of participants ({{ includedMembers.length }}). Matching
requires an even number — exclude one more person or add another
member.
</div>
<div class="flex justify-between space-x-4 pt-2">
<button
class="px-4 py-2 bg-slate-500 text-white rounded hover:bg-slate-700"
@click="cancelPreMatchConfirm"
>
Cancel
</button>
<button
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
@click="confirmAndMatch"
>
Run Matching
</button>
</div>
</div>
</div>
<div
v-if="currentMeeting?.projectLink"
class="mt-8 p-4 border rounded-lg bg-white shadow"
@@ -346,7 +582,9 @@ 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 MeetingMembersList from "../components/MeetingMembersList.vue";
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
@@ -355,7 +593,7 @@ import {
serverMessageForUser,
didInfo,
} from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto";
import { encryptMessage, decryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { APP_SERVER } from "@/constants/app";
@@ -369,6 +607,12 @@ import {
} from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
import {
AxiosErrorResponse,
DoNotPairGroup,
MatchPair,
MeetingExclusionState,
} from "@/interfaces";
interface ServerMeeting {
groupId: number; // from the server
name: string; // to & from the server
@@ -390,7 +634,9 @@ interface MeetingSetupInputs {
components: {
QuickNav,
TopMessage,
MembersList,
MeetingMembersList,
MeetingMemberMatch,
MeetingExclusionGroups,
MeetingProjectDialog,
ProjectIcon,
},
@@ -409,22 +655,114 @@ export default class OnboardMeetingView extends Vue {
currentMeeting: ServerMeeting | null = null;
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
activeDid = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
apiServer = "";
isDeleting = false;
isLoading = true;
isLoadingMatches = false;
isPostingMatch = false;
isRegistered = false;
showDeleteConfirm = false;
fullName = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
matchPairs: MatchPair[] | null = null;
/** Accumulated pair DIDs from every match run; sent as previousPairDids on future posts. */
previousMatchedPairs: [string, string][] = [];
excludedDids: string[] = [];
doNotPairGroups: DoNotPairGroup[] = [];
showPreMatchConfirm = false;
selectedProjectData: PlanData | null = null;
showDeleteConfirm = false;
get minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
return this.formatDateForInput(now);
}
private static readonly EXCLUSION_STORAGE_KEY = "meeting-exclusion-state";
get meetingGroupIdStr(): string {
return this.currentMeeting?.groupId?.toString() || "";
}
get hasActiveMatches(): boolean {
return Array.isArray(this.matchPairs) && this.matchPairs.length > 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<string>();
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<typeof createNotifyHelpers>[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<void> {
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<MatchPair[] | null> {
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<void> {
this.showPreMatchConfirm = false;
await this.postNewMatchesThenRefresh();
}
async postNewMatchesThenRefresh(): Promise<void> {
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<void> {
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();
}

View File

@@ -26,6 +26,31 @@
</router-link>
</div>
<!-- Startup error banner -->
<div
v-if="startupError"
class="max-w-3xl mx-auto mb-6 p-4 bg-red-50 border border-red-300 rounded-lg"
>
<h2 class="text-red-800 font-semibold text-lg mb-2">Startup Error</h2>
<p class="text-red-700 mb-3">
The app encountered a critical error during startup. This is often
caused by a database problem. Please send the details below to
<a :href="`mailto:${SUPPORT_EMAIL}`" class="underline font-semibold">{{
SUPPORT_EMAIL
}}</a>
so we can help resolve it.
</p>
<details class="bg-white border border-red-200 rounded p-3">
<summary class="cursor-pointer text-red-700 font-medium">
Error details
</summary>
<pre
class="mt-2 text-xs text-red-900 whitespace-pre-wrap break-words"
>{{ startupError }}</pre
>
</details>
</div>
<!-- id used by puppeteer test script -->
<div id="start-question">
<div class="max-w-3xl mx-auto">
@@ -35,10 +60,7 @@
<p v-if="PASSKEYS_ENABLED" class="text-center font-light mt-6">
A <strong>passkey</strong> is easy to manage, though it is less
interoperable with other systems for advanced uses.
<a
href="https://www.perplexity.ai/search/what-are-passkeys-v2SHV3yLQlyA2CYH6.Nvhg"
target="_blank"
>
<a href="https://duckduckgo.com/?q=what+is+a+passkey" target="_blank">
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
</a>
</p>
@@ -46,7 +68,7 @@
A <strong>new seed</strong> allows you full control over the keys,
though you are responsible for backups.
<a
href="https://www.perplexity.ai/search/what-is-a-seed-phrase-OqiP9foVRXidr_2le5OFKA"
href="https://duckduckgo.com/?q=what+is+a+seed+phrase"
target="_blank"
>
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
@@ -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;
}

View File

@@ -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

View File

@@ -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);
});

View File

@@ -28,6 +28,9 @@ export async function createBuildConfig(platform: string): Promise<UserConfig> {
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