Compare commits

...

61 Commits

Author SHA1 Message Date
Jose Olarte III
ae49c0e907 feat(ios): implement native share target for images
Implement iOS Share Extension to enable native image sharing from Photos
and other apps directly into TimeSafari. Users can now share images from
the iOS share sheet, which will open in SharedPhotoView for use as gifts
or profile pictures.

iOS Native Implementation:
- Add TimeSafariShareExtension target with ShareViewController
- Configure App Groups for data sharing between extension and main app
- Implement ShareViewController to process shared images and convert to base64
- Store shared image data in App Group UserDefaults
- Add ShareImageBridge utility to read shared data from App Group
- Update AppDelegate to handle shared-photo deep link and bridge data to JS

JavaScript Integration:
- Add checkAndStoreNativeSharedImage() in main.capacitor.ts to read shared
  images from native layer via temporary file bridge
- Convert base64 data to data URL format for compatibility with base64ToBlob
- Integrate with existing SharedPhotoView component
- Add "shared-photo" to deep link validation schema

Build System:
- Integrate Xcode 26 / CocoaPods compatibility workaround into build-ios.sh
- Add run_pod_install_with_workaround() for explicit pod install
- Add run_cap_sync_with_workaround() for Capacitor sync (which runs pod
  install internally)
- Automatically detect project format version 70 and apply workaround
- Remove standalone pod-install-workaround.sh script

Code Cleanup:
- Remove verbose debug logs from ShareViewController, AppDelegate, and
  main.capacitor.ts
- Retain essential logger calls for production debugging

Documentation:
- Add ios-share-extension-setup.md with manual Xcode setup instructions
- Add ios-share-extension-git-commit-guide.md for version control best practices
- Add ios-share-implementation-status.md tracking implementation progress
- Add native-share-target-implementation.md with overall architecture
- Add xcode-26-cocoapods-workaround.md documenting the compatibility issue

The implementation uses a temporary file bridge (AppDelegate writes to Documents
directory, JS reads via Capacitor Filesystem plugin) as a workaround for
Capacitor plugin auto-discovery issues. This can be improved in the future by
properly registering ShareImagePlugin in Capacitor's plugin registry.
2025-11-24 20:46:58 +08:00
Jose Olarte III
1b4ab7a500 fix(ios): add shared Xcode scheme for App target
Resolves "No Scheme" issue in Xcode by adding a shared scheme file.
Capacitor doesn't automatically create shared schemes, so this needs
to be manually added and committed to version control.
2025-11-24 16:20:12 +08:00
6ec2002cb0 Merge pull request 'refactor: move Import Contacts section to DataExportSection component' (#226) from accountview-contact-management-bundling into master
Reviewed-on: #226
2025-11-21 11:02:59 +00:00
Jose Olarte III
36eb9a16b0 fix: preserve contact methods and notes in export/import workflow
- Fix contactMethods not being exported (was checking for string instead of array)
- Add missing notes and iViewContent fields to $insertContact method
- Normalize empty strings to null when saving contacts in ContactEditView

This ensures contact data integrity is maintained during export/import operations.
2025-11-20 18:11:27 +08:00
7d295dd062 feat: make the contact methods more presentable, and clarify exact types 2025-11-19 20:00:42 -07:00
5f1b4dcc21 chore: bump version and add "-beta" 2025-11-19 20:00:09 -07:00
11f122552d chore: bump to version 1.1.3 number 48 2025-11-19 19:58:48 -07:00
c84a3b6705 add instructions to connect to any user profile (#224)
See https://app.clickup.com/t/86b76734v

Reviewed-on: #224
Co-authored-by: Trent Larson <trent@trentlarson.com>
Co-committed-by: Trent Larson <trent@trentlarson.com>
2025-11-19 18:58:49 +00:00
Jose Olarte III
203cf6b078 refactor(DataExportSection): rename section title to "Data Management"
Update the section title from "Data Export" to "Data Management" to better reflect that the component handles both data export and import functionality.
2025-11-19 21:32:35 +08:00
Jose Olarte III
9b84b28a78 refactor: move Import Contacts section to DataExportSection component
- Move Import Contacts UI section from AccountViewView to DataExportSection
- Consolidate import/export functionality in a single component
- Move related methods: uploadImportFile, showContactImport, checkContactImports
- Convert module-level ref to component property (inputImportFileName)
- Remove unused imports (ref, ImportContent) from AccountViewView
- Rename "Download Contacts" button to "Export Contacts"
- Improve import UI styling with full-width file input and button
2025-11-19 19:26:36 +08:00
e64902321f Merge pull request 'fix(GiftedDialog): preserve recipient when changing giver project' (#225) from gifted-dialog-recipient-fix into master
Reviewed-on: #225
2025-11-19 09:56:55 +00:00
7abce8f95c fix: don't count any changed projects on the front page that had blank differences 2025-11-18 19:53:52 -07:00
88dce4d100 fix: show a "project changed" entry if the server reports something 2025-11-18 19:49:40 -07:00
Jose Olarte III
c4eb6f2d1d fix(GiftedDialog): preserve recipient when changing giver project
Modified selectProject() to only set receiver to "You" if no receiver
has been selected yet, preventing recipient from being reset when
changing giver project in Project-to-Person context.
2025-11-18 15:50:11 +08:00
06fdaff879 Merge pull request 'entitygrid-infinite-scroll-improvements' (#223) from entitygrid-infinite-scroll-improvements into master
Reviewed-on: #223
2025-11-18 06:56:55 +00:00
8024a3d02a Merge pull request 'meeting-project-dialog' (#222) from meeting-project-dialog into master
Reviewed-on: #222
2025-11-18 06:56:23 +00:00
Jose Olarte III
223031866b refactor: remove unused loadMoreCallback prop from EntityGrid
Remove loadMoreCallback prop and related backward compatibility code.
No parent components were using this prop, and it has been superseded
by the internal pagination mechanism using fetchProjects() and beforeId.
2025-11-17 19:58:55 +08:00
Jose Olarte III
cb75b25529 refactor: consolidate project loading into EntityGrid component
Unify project loading and searching logic in EntityGrid.vue to eliminate
duplication. Make entities prop optional for projects, add internal
project state, and auto-load projects when needed.

- EntityGrid: Combine search/load into fetchProjects(), add internal
  allProjects state, handle pagination internally for both search and
  load modes
- OnboardMeetingSetupView: Remove project loading methods
- MeetingProjectDialog: Remove project props
- GiftedDialog: Remove project loading logic
- EntitySelectionStep: Make projects prop optional

Reduces code duplication by ~150 lines and simplifies component APIs.
All project selection now uses EntityGrid's internal loading.
2025-11-17 19:49:17 +08:00
83b470e28a fix: link from DID page to Help 2025-11-16 15:35:19 -07:00
Jose Olarte III
acf104eaa7 refactor: remove debug loggers from EntityGrid component
Remove three logger.debug() calls used for debugging project search
results and pagination state. Error logging remains intact.
2025-11-13 21:41:34 +08:00
Jose Olarte III
e793d7a9e2 refactor: defer project loading until MeetingProjectDialog opens
- Move loadProjects() call from created() to handleDialogOpen()
- Remove allProjects check from ensureSelectedProjectLoaded()
- Projects now load only when dialog is opened, improving initial page load performance
- ensureSelectedProjectLoaded() now directly fetches project by handleId when needed
2025-11-13 21:28:47 +08:00
Jose Olarte III
3ecae0be0f refactor(OnboardMeetingSetupView): fix selected project display after refresh
Refactor selectedProject computation to use separate storage instead of
relying on allProjects array. This fixes a bug where the selected project
wouldn't display after page refresh if it wasn't in the initial allProjects
batch.

Changes:
- Add selectedProjectData property to store selected project independently
- Simplify selectedProject computed to return selectedProjectData directly
- Add fetchProjectByHandleId() to fetch single project by handleId
- Add ensureSelectedProjectLoaded() to check allProjects first, then fetch
- Update handleProjectLinkAssigned() to store directly in selectedProjectData
- Remove band-aid solution of adding selected projects to allProjects array
- Update startEditing() and cancelEditing() to ensure selected project loads
- Call ensureSelectedProjectLoaded() in created() lifecycle hook

This ensures the selected project always displays correctly, even when:
- Selected from search results (not in allProjects)
- Page is refreshed (allProjects reloads without selected project)
- Project is in a later pagination batch
2025-11-13 19:21:22 +08:00
Jose Olarte III
d37e53b1a9 fix: pause MembersList auto-refresh during project dialog interaction
Stop auto-refresh when MeetingProjectDialog opens and resume when it closes
to prevent UI conflicts during project selection.
2025-11-13 18:10:35 +08:00
Jose Olarte III
2f89c7e13b feat(EntityGrid): add server-side search with pagination for projects
Implement server-side search for projects using API endpoint with
pagination support via beforeId parameter. Contacts continue using
client-side filtering from complete local database.

- Add PlatformServiceMixin for internal apiServer access
- Implement performProjectSearch() with pagination
- Update infinite scroll to handle search pagination
- Add search lifecycle management and error handling

No breaking changes to parent components.
2025-11-12 21:06:20 +08:00
Jose Olarte III
6bf4055c2f feat: add pagination support for project lists in dialogs
Add server-side pagination to EntityGrid component for projects, enabling
infinite scrolling to load all available projects instead of stopping after
the initial batch.

Changes:
- EntityGrid: Add loadMoreCallback prop to trigger server-side loading when
  scroll reaches end of loaded projects
- OnboardMeetingSetupView: Update loadProjects() to support pagination with
  beforeId parameter and add handleLoadMoreProjects() callback
- MeetingProjectDialog: Accept and pass through loadMoreCallback to EntityGrid
- GiftedDialog: Add pagination support to loadProjects() and
  handleLoadMoreProjects() callback
- EntitySelectionStep: Accept and pass through loadMoreCallback prop to
  EntityGrid when showing projects

This ensures users can access all projects in MeetingProjectDialog and
GiftedDialog by automatically loading more as they scroll, matching the
behavior already present in DiscoverView.

All project uses of EntityGrid now use pagination by default.
2025-11-12 17:10:03 +08:00
Jose Olarte III
bf7ee630d0 feat(meeting): enable selecting all projects in meeting setup
Update loadProjects to fetch all projects instead of only user's projects
by switching from plansByIssuer to plans endpoint.
2025-11-12 15:58:23 +08:00
1739567b18 Merge pull request 'feat: replace authorized representative input with contact selection dialog' (#219) from project-representative-dialog into master
Reviewed-on: #219
2025-11-12 01:42:48 -05:00
Jose Olarte III
a5a9af5ddc feat(meetings): add project selection dialog for meeting setup
Replace Project Link text input with interactive selection dialog
using new MeetingProjectDialog component. Dialog displays user's
projects with icons and issuer information, following the same
pattern as ProjectRepresentativeDialog.

- Create MeetingProjectDialog with EntityGrid integration
- Add clickable project field with icon, name, and issuer display
- Load projects from /api/v2/report/plansByIssuer endpoint
- Show issuer name instead of handleId for better UX
- Refactor loadProjects to remove unused rowId field
2025-11-11 21:34:11 +08:00
Jose Olarte III
4e3e293495 refactor(EntityGrid): simplify alphabetical section label
Change "Everyone Else" to "Everyone" for clearer, more concise labeling
2025-11-11 15:32:11 +08:00
Jose Olarte III
65533c15d2 Merge branch 'project-representative-dialog' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into project-representative-dialog 2025-11-11 15:15:01 +08:00
Jose Olarte III
2530bc0ec2 fix: ensure consistent "Recently Added" contacts in ProjectRepresentativeDialog
EntityGrid's recentContacts assumes contacts are sorted by date added
(newest first), but ProjectRepresentativeDialog was receiving contacts
sorted alphabetically from NewEditProjectView, causing it to show
different "Recently Added" contacts than GiftedDialog.

- Changed NewEditProjectView to use $contactsByDateAdded() instead of
  $getAllContacts()
- Added documentation comments to EntityGrid.vue to prevent this issue
  in future reuses
2025-11-11 15:06:07 +08:00
5050156beb fix: a type, plus add the type-check to the mobile build scripts 2025-11-08 08:31:42 -07:00
b1fa6ac458 feat: show the recent contacts in the alphabetical section of choosers 2025-11-07 18:27:05 -07:00
9ff24f8258 fix: in project-edit view, don't show agent warning on new one, and automatically switch if they're changing 2025-11-07 18:11:11 -07:00
Jose Olarte III
9a3409c29f refactor: remove unused code from ProjectRepresentativeDialog
- Remove conflictChecker prop (always passed as no-op function)
- Remove unused emitCancel method and cancel event handling
- Simplify handleEntitySelected by removing unnecessary type check
- Update NewEditProjectView to remove conflict-checker binding and empty cancel handler

The conflictChecker prop was not needed since representative selection
doesn't require conflict detection. The cancel event was never emitted
and the parent handler was empty, so both were removed.
2025-11-07 17:43:44 +08:00
d265a9f78c chore: bump version and add "-beta" 2025-11-06 08:56:33 -07:00
f848de15f1 chore: bump version to 1.1.2 build 47 (for fix to seed backup) 2025-11-06 08:54:11 -07:00
ebaf2dedf0 Merge pull request 'fix: database connection error causing navigation redirect on iOS/Android' (#220) from fix-sqlite-connection-error-mobile into master
Reviewed-on: #220
2025-11-06 09:52:31 -05:00
Jose Olarte III
749204f96b fix: database connection error causing navigation redirect on iOS/Android
Handle "Connection already exists" error when initializing SQLite database
on Capacitor platforms. The native connection can persist across app
restarts while the JavaScript connection Map is empty, causing a mismatch.

When createConnection fails with "already exists":
- Check if connection exists in JavaScript Map and retrieve it if present
- If not in Map, close the native connection and recreate to sync both sides
- Handle "already open" errors gracefully when opening existing connections

This fixes the issue where clicking "Backup Identifier Seed" would redirect
to StartView instead of SeedBackupView due to database initialization
failures in the router navigation guard.

Fixes navigation issue on both iOS and Android platforms.
2025-11-06 21:38:51 +08:00
Jose Olarte III
a142737771 feat: replace authorized representative input with contact selection dialog
Replace the plain text input for authorized representative with an
interactive contact selection interface that provides better UX and
maintains data consistency.

Changes:
- Add ProjectRepresentativeDialog component using EntityGrid for contact selection (excludes "You" and "Unnamed" special entities)
- Replace text input with clickable field showing contact icon, name, and DID
- Implement conditional UI states: initial "Assign..." placeholder vs assigned representative display with unset button
- Refactor selectedRepresentative to computed property derived from agentDid (single source of truth, prevents sync issues)
- Inline representativeDisplayName for simplicity
- Support changing representative by clicking on assigned field
- Support unsetting representative via trash button

The new implementation ensures agentDid remains the authoritative state while selectedRepresentative is automatically computed, preventing the previously possible desync when agentDid was set directly (e.g., via the
"make original owner an authorized representative" button).
2025-11-05 20:20:43 +08:00
1053bb6e4c Merge pull request 'bulk-members-dialog-refactor' (#218) from bulk-members-dialog-refactor into master
Reviewed-on: #218
2025-11-05 03:34:27 -05:00
88f46787e5 Merge pull request 'entity-selection-list-component' (#216) from entity-selection-list-component into master
Reviewed-on: #216
2025-11-05 03:25:35 -05:00
Jose Olarte III
d9230d0be8 fix: restore proper dialog max-height 2025-11-05 16:25:06 +08:00
Jose Olarte III
38f301f053 Merge branch 'master' into entity-selection-list-component 2025-11-05 16:12:39 +08:00
e42552c67a Merge pull request 'feat(EntityGrid): implement infinite scroll for entity lists' (#215) from entity-selection-list-component-infinite-scroll into entity-selection-list-component
Reviewed-on: #215
2025-11-05 02:52:30 -05:00
0e3c6cb314 chore: bump version to 1.1.2-beta 2025-11-04 08:38:01 -07:00
232b787b37 chore: bump to version 1.1.1 build 46 (emojis, starred projects, improved onboarding meetings) 2025-11-04 08:36:08 -07:00
Jose Olarte III
c06ffec466 refactor: combine member processing methods in BulkMembersDialog
Consolidate organizerAdmitAndAddWithVisibility() and
memberAddContactWithVisibility() into a single unified method
processSelectedMembers() that handles both organizer and member
modes based on the isOrganizer prop.

- Remove redundant handleMainAction() wrapper method
- Update template to call processSelectedMembers directly
- Reduce code duplication by ~30% (140 lines → 98 lines)
- Maintain identical functionality for both modes

This simplifies the component structure and makes the processing
logic easier to maintain.
2025-11-04 18:39:45 +08:00
Jose Olarte III
8b199ec76c refactor: remove redundant dialogType prop from BulkMembersDialog
Remove dialogType prop and consolidate to use only isOrganizer prop.

- Remove dialogType prop from BulkMembersDialog component
- Replace all dialogType checks with isOrganizer boolean checks
- Add comments clarifying isOrganizer true/false meanings
- Remove dialog-type prop binding from MembersList component

This simplifies the component API while maintaining the same functionality.
2025-11-04 17:57:38 +08:00
7e861e2fca fix: when organizer adds people, they automatically register them as well 2025-11-03 20:21:34 -07:00
73806e78bc refactor: fix the 'back' links to work consistently, so contact pages can be included in other flows 2025-11-03 19:06:01 -07:00
Jose Olarte III
d32cca4f53 feat(EntityGrid): implement infinite scroll for entity lists
Add infinite scroll functionality to EntityGrid component using VueUse's
useInfiniteScroll composable to handle large volumes of entities efficiently.

Changes:
- Integrate @vueuse/core useInfiniteScroll composable
- Add infinite scroll state management (displayedCount, reset function)
- Configure initial batch size (20 items) and increment size (20 items)
- Update displayedEntities, alphabeticalContacts to support progressive loading
- Add canLoadMore() logic for people, projects, and search modes
- Reset scroll state when search term or entities prop changes
- Remove maxItems prop (replaced by infinite scroll)
- Simplify displayEntitiesFunction signature (removed maxItems parameter)
- Update EntitySelectionStep and test files to remove max-items prop

Technical details:
- Uses template ref (scrollContainer) to access scrollable container
- Recent contacts (3) count toward initial batch for people grid
- Special entities (You, Unnamed) always displayed, don't count toward limits
- Infinite scroll works for both entity types and search results
- Constants are configurable at top of component (INITIAL_BATCH_SIZE, INCREMENT_SIZE)

This improves performance and UX when displaying large lists of contacts or
projects by loading content progressively as users scroll.
2025-11-03 21:47:25 +08:00
Jose Olarte III
4004d9fe52 feat(EntityGrid): Split contacts into recent and alphabetical sections
When displaying contacts (not search results), show the 3 most recently
added contacts at the top with a "Recently Added" heading, followed by
the rest sorted alphabetically with an "Everyone Else" heading.

- Add recentContacts and alphabeticalContacts computed properties
- Hide "You" and "Unnamed" special entities during search
- Only show search spinner when actively searching with a term
- Style section headings with uppercase, improved spacing, and borders
2025-11-03 16:32:59 +08:00
Jose Olarte III
2f99d0b416 fix(components): prevent icon shrinking in PersonCard and ProjectCard
Add shrink-0 class to icon elements to maintain consistent icon sizing
when card layouts flex or wrap content.
2025-10-31 19:10:13 +08:00
Jose Olarte III
9c3002f9c7 feat(EntityGrid): sort search results alphabetically
Sort search results alphabetically while preserving original order for
default list when no search term is present.
2025-10-31 19:07:50 +08:00
Jose Olarte III
82fd7cddf7 feat: Add showUnnamedEntity prop to EntityGrid
Add prop to control visibility of "Unnamed" entity, matching showYouEntity
pattern. Defaults to true for backward compatibility.
2025-10-31 18:59:35 +08:00
Jose Olarte III
10f2920e11 feat(EntityGrid): display no results message for empty search queries
Add contextual feedback message when a search term is entered but no matching entities are found. The message dynamically adjusts its wording based on whether searching for people or projects.
2025-10-31 18:34:54 +08:00
Jose Olarte III
75c89b471c fix: linting 2025-10-30 21:49:35 +08:00
Jose Olarte III
a804877a08 feat: Add quick search to EntityGrid with date-based contact sorting
- Add search-as-you-type functionality with 500ms debounce
- Implement search across contact names and DIDs, project names and handleIds
- Add loading spinner and dynamic clear button
- Add $contactsByDateAdded() method to PlatformServiceMixin for newest-first sorting
- Update GiftedDialog to use date-based contact ordering
- Maintain backward compatibility with existing $contacts() alphabetical sorting
- Add proper cleanup for search timeouts on component unmount

The search feature provides real-time filtering with visual feedback,
while the new sorting ensures recently added contacts appear first.
2025-10-30 21:16:36 +08:00
Jose Olarte III
f7441f39e7 feat: remove Show All navigation card from entity grids
- Remove ShowAllCard component and all related functionality
- Remove showAllRoute, showAllQueryParams, and hideShowAll props
- Remove shouldShowAll computed property from EntityGrid
- Clean up ShowAll-related code from EntitySelectionStep and GiftedDialog
- Delete ShowAllCard.vue component file
- Update component documentation to reflect removal

This simplifies the entity selection interface by removing the navigation
card that allowed users to view all entities in a separate view.
2025-10-30 17:31:18 +08:00
Jose Olarte III
e647af0777 refactor: convert entity display to list style
- Switch from grid display to list layout for persons and projects
- Re-styled special entities (unnamed, You) to match
- Added max-height limit to list in preparation for scrolling and displaying more items
2025-10-24 13:26:36 +08:00
56 changed files with 4258 additions and 766 deletions

View File

@@ -1151,28 +1151,28 @@ If you need to build manually or want to understand the individual steps:
- ... and you may have to fix these, especially with pkgx:
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
##### 1. Bump the version in package.json, then here
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
```bash
cd ios/App && xcrun agvtool new-version 40 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.7;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
```bash
cd ios/App && xcrun agvtool new-version 48 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.3;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
##### 2. Build
Here's prod. Also available: test, dev
Here's prod. Also available: test, dev
```bash
npm run build:ios:prod
```
```bash
npm run build:ios:prod
```
3.1. Use Xcode to build and run on simulator or device.
@@ -1197,7 +1197,8 @@ If you need to build manually or want to understand the individual steps:
- It can take 15 minutes for the build to show up in the list of builds.
- You'll probably have to "Manage" something about encryption, disallowed in France.
- Then "Save" and "Add to Review" and "Resubmit to App Review".
- Eventually it'll be "Ready for Distribution" which means
- Eventually it'll be "Ready for Distribution" which means it's live
- When finished, bump package.json version
### Android Build
@@ -1303,8 +1304,8 @@ The recommended way to build for Android is using the automated build script:
# Standard build and open Android Studio
./scripts/build-android.sh
# Build with specific version numbers
./scripts/build-android.sh --version 1.0.3 --build-number 35
# Build with specific version numbers -- doesn't change source files
#./scripts/build-android.sh --version 1.1.3 --build-number 48
# Build without opening Android Studio (for CI/CD)
./scripts/build-android.sh --no-studio
@@ -1315,26 +1316,26 @@ The recommended way to build for Android is using the automated build script:
#### Android Manual Build Process
##### 1. Bump the version in package.json, then here: android/app/build.gradle
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 40/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.0.7"/g' android/app/build.gradle
```
```bash
perl -p -i -e 's/versionCode .*/versionCode 48/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.3"/g' android/app/build.gradle
```
##### 2. Build
Here's prod. Also available: test, dev
```bash
npm run build:android:prod
```
```bash
npm run build:android:prod
```
##### 3. Open the project in Android Studio
```bash
npx cap open android
```
```bash
npx cap open android
```
##### 4. Use Android Studio to build and run on emulator or device
@@ -1379,6 +1380,8 @@ At play.google.com/console:
- Note that if you add testers, you have to go to "Publishing Overview" and send
those changes or your (closed) testers won't see it.
- When finished, bump package.json version
### Capacitor Operations
```bash

View File

@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
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.1.3] - 2025.11.19
### Changed
- Project selection in dialogs now reaches out to server when filtering
- Project selection during onboarding meeting is a search (not an input box)
- Improve the switching of agent when agent edits a project
### Fixed
- Reassignment of "you" as recipient when changing giver project
- Bad counts for project-change notification on front page
## [1.1.2] - 2025.11.06
### Fixed
- Bad page when user follows prompt to backup seed
## [1.1.1] - 2025.11.03
### Added
- Meeting onboarding via prompts
- Emojis on gift feed
- Starred projects with notification
## [1.0.7] - 2025.08.18
### Fixed

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 41
versionName "1.0.8"
versionCode 48
versionName "1.1.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -0,0 +1,139 @@
# iOS Share Extension - Git Commit Guide
**Date:** 2025-01-27
**Purpose:** Clarify which Xcode manual changes should be committed to the repository
## Quick Answer
**YES, most manual Xcode changes SHOULD be committed.** The only exceptions are user-specific settings that are already gitignored.
## What Gets Modified (and Should Be Committed)
When you create the Share Extension target and configure App Groups in Xcode, the following files are modified:
### 1. `ios/App/App.xcodeproj/project.pbxproj` ✅ **COMMIT THIS**
This is the main Xcode project file that tracks:
- **New targets** (Share Extension target)
- **File references** (which files belong to which targets)
- **Build settings** (compiler flags, deployment targets, etc.)
- **Build phases** (compile sources, link frameworks, etc.)
- **Capabilities** (App Groups configuration)
- **Target dependencies**
**This file IS tracked in git** (not in `.gitignore`), so changes should be committed.
### 2. Entitlements Files ✅ **COMMIT THESE**
When you enable App Groups capability, Xcode creates/modifies:
- `ios/App/App/App.entitlements` (for main app)
- `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` (for extension)
These files contain the App Group identifiers and should be committed.
### 3. Share Extension Source Files ✅ **ALREADY COMMITTED**
The following files are already in the repo:
- `ios/App/TimeSafariShareExtension/ShareViewController.swift`
- `ios/App/TimeSafariShareExtension/Info.plist`
- `ios/App/App/ShareImageBridge.swift`
These should already be committed (they were created as part of the implementation).
## What Should NOT Be Committed
### 1. User-Specific Settings ❌ **ALREADY GITIGNORED**
These are in `ios/.gitignore`:
- `xcuserdata/` - User-specific scheme selections, breakpoints, etc.
- `*.xcuserstate` - User's current Xcode state
### 2. Signing Identities ❌ **USER-SPECIFIC**
While the **App Groups capability** should be committed (it's in `project.pbxproj` and entitlements), your **personal signing identity/team** is user-specific and Xcode handles this automatically per developer.
## What Happens When You Commit
When you commit the changes:
1. **Other developers** who pull the changes will:
- ✅ Get the new Share Extension target automatically
- ✅ Get the App Groups capability configuration
- ✅ Get file references and build settings
- ✅ See the Share Extension in their Xcode project
2. **They will still need to:**
- Configure their own signing team/identity (Xcode prompts for this)
- Build the project (which may trigger CocoaPods updates)
- But they **won't** need to manually create the target or configure App Groups
## Step-by-Step: What to Commit
After completing the Xcode setup steps:
```bash
# Check what changed
git status
# You should see:
# - ios/App/App.xcodeproj/project.pbxproj (modified)
# - ios/App/App/App.entitlements (new or modified)
# - ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements (new)
# - Possibly other project-related files
# Review the changes
git diff ios/App/App.xcodeproj/project.pbxproj
# Commit the changes
git add ios/App/App.xcodeproj/project.pbxproj
git add ios/App/App/App.entitlements
git add ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements
git commit -m "Add iOS Share Extension target and App Groups configuration"
```
## Important Notes
### Merge Conflicts in project.pbxproj
The `project.pbxproj` file can have merge conflicts because:
- It's auto-generated by Xcode
- Multiple developers might modify it
- It uses UUIDs that can conflict
**If you get merge conflicts:**
1. Open the project in Xcode
2. Xcode will often auto-resolve conflicts
3. Or manually resolve by keeping both sets of changes
4. Test that the project builds
### Team/Developer IDs
The `DEVELOPMENT_TEAM` setting in `project.pbxproj` might be user-specific:
- Some teams commit this (if everyone uses the same team)
- Some teams use `.xcconfig` files to override per developer
- Check with your team's practices
If you see `DEVELOPMENT_TEAM = GM3FS5JQPH;` in the project file, this is already committed, so your team likely commits team IDs.
## Verification
After committing, verify that:
1. The Share Extension target appears in Xcode for other developers
2. App Groups capability is configured
3. The project builds successfully
4. No user-specific files were accidentally committed
## Summary
| Change Type | Commit? | Reason |
|------------|---------|--------|
| New target creation | ✅ Yes | Modifies `project.pbxproj` |
| App Groups capability | ✅ Yes | Creates/modifies entitlements files |
| File target membership | ✅ Yes | Modifies `project.pbxproj` |
| Build settings | ✅ Yes | Modifies `project.pbxproj` |
| Source files (Swift, plist) | ✅ Yes | Already in repo |
| User scheme selections | ❌ No | In `xcuserdata/` (gitignored) |
| Personal signing identity | ⚠️ Maybe | Depends on team practice |
**Bottom line:** Commit all the Xcode project configuration changes. Other developers will get the Share Extension target automatically when they pull, and they'll only need to configure their personal signing settings.

View File

@@ -0,0 +1,140 @@
# iOS Share Extension Setup Instructions
**Date:** 2025-01-27
**Purpose:** Step-by-step instructions for setting up the iOS Share Extension in Xcode
## Prerequisites
- Xcode installed
- iOS project already set up with Capacitor
- Access to Apple Developer account (for App Groups)
## Step 1: Create Share Extension Target
1. Open `ios/App/App.xcodeproj` in Xcode
2. In the Project Navigator, select the **App** project (top-level item)
3. Click the **+** button at the bottom of the Targets list
4. Select **iOS****Share Extension**
5. Click **Next**
6. Configure:
- **Product Name:** `TimeSafariShareExtension`
- **Bundle Identifier:** `app.timesafari.shareextension` (must match main app's bundle ID with `.shareextension` suffix)
- **Language:** Swift
7. Click **Finish**
## Step 2: Configure Share Extension Files
The following files have been created in `ios/App/TimeSafariShareExtension/`:
- `ShareViewController.swift` - Main extension logic
- `Info.plist` - Extension configuration
**Verify these files exist and are added to the Share Extension target.**
## Step 3: Configure App Groups
App Groups allow the Share Extension and main app to share data.
### For Main App Target:
1. Select the **App** target in Xcode
2. Go to **Signing & Capabilities** tab
3. Click **+ Capability**
4. Select **App Groups**
5. Click **+** to add a new group
6. Enter: `group.app.timesafari`
7. Ensure it's checked/enabled
### For Share Extension Target:
1. Select the **TimeSafariShareExtension** target
2. Go to **Signing & Capabilities** tab
3. Click **+ Capability**
4. Select **App Groups**
5. Click **+** to add a new group
6. Enter: `group.app.timesafari` (same as main app)
7. Ensure it's checked/enabled
**Important:** Both targets must use the **exact same** App Group identifier.
## Step 4: Configure Share Extension Info.plist
The `Info.plist` file should already be configured, but verify:
1. Select `TimeSafariShareExtension/Info.plist` in Xcode
2. Ensure it contains:
- `NSExtensionPointIdentifier` = `com.apple.share-services`
- `NSExtensionPrincipalClass` = `$(PRODUCT_MODULE_NAME).ShareViewController`
- `NSExtensionActivationSupportsImageWithMaxCount` = `1`
## Step 5: Add ShareImageBridge to Main App
1. The file `ios/App/App/ShareImageBridge.swift` has been created
2. Ensure it's added to the **App** target (not the Share Extension target)
3. In Xcode, select the file and check the **Target Membership** in the File Inspector
## Step 6: Build and Test
1. Select the **App** scheme (not the Share Extension scheme)
2. Build and run on a device or simulator
3. Open Photos app
4. Select an image
5. Tap **Share** button
6. Look for **TimeSafari Share** in the share sheet
7. Select it
8. The app should open and navigate to the shared photo view
## Step 7: Troubleshooting
### Share Extension doesn't appear in share sheet
- Verify the Share Extension target builds successfully
- Check that `Info.plist` is correctly configured
- Ensure the extension's bundle identifier follows the pattern: `{main-app-bundle-id}.shareextension`
- Clean build folder (Product → Clean Build Folder)
### App Group access fails
- Verify both targets have the same App Group identifier
- Check that App Groups capability is enabled for both targets
- Ensure you're signed in with a valid Apple Developer account
- For development, you may need to enable App Groups in your Apple Developer account
### Shared image not appearing
- Check Xcode console for errors
- Verify `ShareViewController.swift` is correctly implemented
- Ensure the deep link `timesafari://shared-photo` is being handled
- Check that the native bridge method is being called
### Build errors
- Ensure Swift version matches between targets
- Check that all required frameworks are linked
- Verify deployment targets match between main app and extension
## Step 8: Native Bridge Implementation (TODO)
Currently, the JavaScript code needs a way to call the native `getSharedImageData()` method. This requires one of:
1. **Option A:** Create a minimal Capacitor plugin
2. **Option B:** Use Capacitor's existing bridge mechanisms
3. **Option C:** Expose the method via a custom URL scheme parameter
The current implementation in `main.capacitor.ts` has a placeholder that needs to be completed.
## Next Steps
After the Share Extension is set up and working:
1. Complete the native bridge implementation to read from App Group
2. Test end-to-end flow: Share image → Extension stores → App reads → Displays
3. Implement Android version
4. Add error handling and edge cases
## References
- [Apple Share Extensions Documentation](https://developer.apple.com/documentation/social)
- [App Groups Documentation](https://developer.apple.com/documentation/xcode/configuring-app-groups)
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)

View File

@@ -0,0 +1,93 @@
# iOS Share Extension Implementation Status
**Date:** 2025-01-27
**Status:** In Progress - Native Code Complete, Bridge Pending
## Completed
**Share Extension Files Created:**
- `ios/App/TimeSafariShareExtension/ShareViewController.swift` - Handles image sharing
- `ios/App/TimeSafariShareExtension/Info.plist` - Extension configuration
**Native Bridge Created:**
- `ios/App/App/ShareImageBridge.swift` - Native method to read from App Group
**JavaScript Integration Started:**
- `src/services/nativeShareHandler.ts` - Service to handle native shared images
- `src/main.capacitor.ts` - Updated to check for native shared images on deep link
**Documentation:**
- `doc/native-share-target-implementation.md` - Complete implementation guide
- `doc/ios-share-extension-setup.md` - Xcode setup instructions
## Pending
⚠️ **Xcode Configuration (Manual Steps Required):**
1. Create Share Extension target in Xcode
2. Configure App Groups for both main app and extension
3. Add ShareImageBridge.swift to App target
4. Build and test
⚠️ **JavaScript-Native Bridge:**
The current implementation has a placeholder for calling the native `ShareImageBridge.getSharedImageData()` method from JavaScript. This needs to be completed using one of:
**Option A: Minimal Capacitor Plugin** (Recommended for Option 1)
- Create a small plugin that exposes the method
- Clean and maintainable
- Follows Capacitor patterns
**Option B: Direct Bridge Call**
- Use Capacitor's executePlugin or similar mechanism
- Requires understanding Capacitor's internal bridge
- Less maintainable
**Option C: AppDelegate Integration**
- Have AppDelegate check on launch and expose via a different mechanism
- Workaround approach
- Less clean but functional
## Next Steps
1. **Complete Xcode Setup:**
- Follow `doc/ios-share-extension-setup.md`
- Create Share Extension target
- Configure App Groups
- Build and verify extension appears in share sheet
2. **Implement JavaScript-Native Bridge:**
- Choose one of the options above
- Complete the `checkAndStoreNativeSharedImage()` function in `main.capacitor.ts`
- Test end-to-end flow
3. **Testing:**
- Share image from Photos app
- Verify Share Extension appears
- Verify app opens and displays shared image
- Test "Record Gift" and "Save as Profile" flows
## Current Flow
1. ✅ User shares image → Share Extension receives
2. ✅ Share Extension converts to base64
3. ✅ Share Extension stores in App Group UserDefaults
4. ✅ Share Extension opens app with `timesafari://shared-photo?fileName=...`
5. ⚠️ App receives deep link (handled)
6. ⚠️ App checks App Group UserDefaults (bridge needed)
7. ⚠️ App stores in temp database (pending bridge)
8. ✅ SharedPhotoView reads from temp database (already works)
## Code Locations
- **Share Extension:** `ios/App/TimeSafariShareExtension/`
- **Native Bridge:** `ios/App/App/ShareImageBridge.swift`
- **JavaScript Handler:** `src/services/nativeShareHandler.ts`
- **Deep Link Integration:** `src/main.capacitor.ts`
- **View Component:** `src/views/SharedPhotoView.vue` (already complete)
## Notes
- The Share Extension code is complete and ready to use
- The main missing piece is the JavaScript-to-native bridge
- Once the bridge is complete, the entire flow should work end-to-end
- The existing `SharedPhotoView.vue` doesn't need changes - it already handles images from temp storage

View File

@@ -0,0 +1,507 @@
# Native Share Target Implementation Guide
**Date:** 2025-01-27
**Purpose:** Enable TimeSafari native iOS and Android apps to receive shared images from other apps
## Current State
The app currently supports **PWA/web share target** functionality:
- Service worker intercepts POST to `/share-target`
- Images stored in temp database as base64
- `SharedPhotoView.vue` processes and displays shared images
**This does NOT work for native iOS/Android builds** because:
- Service workers don't run in native app contexts
- Native platforms use different sharing mechanisms (Share Extensions on iOS, Intent Filters on Android)
## Required Changes
### 1. iOS Implementation
#### 1.1 Create Share Extension Target
1. Open `ios/App/App.xcodeproj` in Xcode
2. File → New → Target
3. Select "Share Extension" template
4. Name it "TimeSafariShareExtension"
5. Bundle Identifier: `app.timesafari.shareextension`
6. Language: Swift
#### 1.2 Configure Share Extension Info.plist
Add to `ios/App/TimeSafariShareExtension/Info.plist`:
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
```
#### 1.3 Implement ShareViewController
Create `ios/App/TimeSafariShareExtension/ShareViewController.swift`:
```swift
import UIKit
import Social
import MobileCoreServices
import Capacitor
class ShareViewController: SLComposeServiceViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.title = "Share to TimeSafari"
}
override func isContentValid() -> Bool {
return true
}
override func didSelectPost() {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let itemProvider = extensionItem.attachments?.first else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// Handle image sharing
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
itemProvider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
guard let self = self else { return }
if let url = item as? URL {
// Handle file URL
self.handleSharedImage(url: url)
} else if let image = item as? UIImage {
// Handle UIImage directly
self.handleSharedImage(image: image)
} else if let data = item as? Data {
// Handle image data
self.handleSharedImage(data: data)
}
}
}
}
private func handleSharedImage(url: URL? = nil, image: UIImage? = nil, data: Data? = nil) {
var imageData: Data?
var fileName: String?
if let url = url {
imageData = try? Data(contentsOf: url)
fileName = url.lastPathComponent
} else if let image = image {
imageData = image.jpegData(compressionQuality: 0.8)
fileName = "shared-image.jpg"
} else if let data = data {
imageData = data
fileName = "shared-image.jpg"
}
guard let imageData = imageData else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// Convert to base64
let base64String = imageData.base64EncodedString()
// Store in shared UserDefaults (accessible by main app)
let userDefaults = UserDefaults(suiteName: "group.app.timesafari")
userDefaults?.set(base64String, forKey: "sharedPhotoBase64")
userDefaults?.set(fileName ?? "shared-image.jpg", forKey: "sharedPhotoFileName")
userDefaults?.synchronize()
// Open main app with deep link
let url = URL(string: "timesafari://shared-photo?fileName=\(fileName?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "shared-image.jpg")")!
var responder = self as UIResponder?
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
// Close share extension
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
override func configurationItems() -> [Any]! {
return []
}
}
```
#### 1.4 Configure App Groups
1. In Xcode, select main app target → Signing & Capabilities
2. Add "App Groups" capability
3. Create group: `group.app.timesafari`
4. Repeat for Share Extension target with same group name
#### 1.5 Update Main App to Read from App Group
The main app needs to check for shared images on launch. This should be added to `AppDelegate.swift` or handled in JavaScript.
### 2. Android Implementation
#### 2.1 Update AndroidManifest.xml
Add intent filter to `MainActivity` in `android/app/src/main/AndroidManifest.xml`:
```xml
<activity
android:name=".MainActivity"
... existing attributes ...>
... existing intent filters ...
<!-- Share Target Intent Filter -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Multiple images support (optional) -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
```
#### 2.2 Handle Intent in MainActivity
Update `android/app/src/main/java/app/timesafari/MainActivity.java`:
```java
package app.timesafari;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Base64;
import android.util.Log;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
public class MainActivity extends BridgeActivity {
private static final String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
handleShareIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleShareIntent(intent);
}
private void handleShareIntent(Intent intent) {
if (intent == null) return;
String action = intent.getAction();
String type = intent.getType();
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
Uri imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (imageUri != null) {
handleSharedImage(imageUri, intent.getStringExtra(Intent.EXTRA_TEXT));
}
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
// Handle multiple images (optional - for now just take first)
java.util.ArrayList<Uri> imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (imageUris != null && !imageUris.isEmpty()) {
handleSharedImage(imageUris.get(0), null);
}
}
}
private void handleSharedImage(Uri imageUri, String fileName) {
try {
// Read image data
InputStream inputStream = getContentResolver().openInputStream(imageUri);
if (inputStream == null) {
Log.e(TAG, "Failed to open input stream for shared image");
return;
}
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] data = new byte[8192];
int nRead;
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
}
buffer.flush();
byte[] imageBytes = buffer.toByteArray();
// Convert to base64
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
// Extract filename from URI or use default
String actualFileName = fileName;
if (actualFileName == null || actualFileName.isEmpty()) {
String path = imageUri.getPath();
if (path != null) {
int lastSlash = path.lastIndexOf('/');
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
actualFileName = path.substring(lastSlash + 1);
}
}
if (actualFileName == null || actualFileName.isEmpty()) {
actualFileName = "shared-image.jpg";
}
}
// Store in SharedPreferences (accessible by JavaScript via Capacitor)
android.content.SharedPreferences prefs = getSharedPreferences("TimeSafariShared", MODE_PRIVATE);
android.content.SharedPreferences.Editor editor = prefs.edit();
editor.putString("sharedPhotoBase64", base64String);
editor.putString("sharedPhotoFileName", actualFileName);
editor.apply();
// Trigger JavaScript event or navigate to shared-photo route
// This will be handled by JavaScript checking for shared data on app launch
Log.d(TAG, "Shared image stored, filename: " + actualFileName);
} catch (Exception e) {
Log.e(TAG, "Error handling shared image", e);
}
}
}
```
#### 2.3 Add Required Permissions
Ensure `AndroidManifest.xml` has:
```xml
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- Android 13+ -->
```
### 3. JavaScript Layer Updates
#### 3.1 Create Native Share Handler
Create `src/services/nativeShareHandler.ts`:
```typescript
/**
* Native Share Handler
* Handles shared images from native iOS and Android platforms
*/
import { Capacitor } from "@capacitor/core";
import { App } from "@capacitor/app";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { logger } from "../utils/logger";
import { SHARED_PHOTO_BASE64_KEY } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
/**
* Check for shared images from native platforms and store in temp database
*/
export async function checkForNativeSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
if (!Capacitor.isNativePlatform()) {
return false;
}
try {
if (Capacitor.getPlatform() === "ios") {
return await checkIOSSharedImage(platformService);
} else if (Capacitor.getPlatform() === "android") {
return await checkAndroidSharedImage(platformService);
}
} catch (error) {
logger.error("Error checking for native shared image:", error);
}
return false;
}
/**
* Check for shared image on iOS (from App Group UserDefaults)
*/
async function checkIOSSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
// iOS uses App Groups to share data between extension and main app
// We need to use a Capacitor plugin or native code to read from App Group
// For now, this is a placeholder - requires native plugin implementation
// Option 1: Use Capacitor plugin to read from App Group
// Option 2: Use native code bridge
logger.debug("Checking for iOS shared image (not yet implemented)");
return false;
}
/**
* Check for shared image on Android (from SharedPreferences)
*/
async function checkAndroidSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
// Android stores in SharedPreferences
// We need a Capacitor plugin to read from SharedPreferences
// For now, this is a placeholder - requires native plugin implementation
logger.debug("Checking for Android shared image (not yet implemented)");
return false;
}
/**
* Store shared image in temp database
*/
async function storeSharedImage(
base64Data: string,
fileName: string,
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<void> {
try {
const existing = await platformService.$getTemp(SHARED_PHOTO_BASE64_KEY);
if (existing) {
await platformService.$updateEntity(
"temp",
{ blobB64: base64Data },
"id = ?",
[SHARED_PHOTO_BASE64_KEY]
);
} else {
await platformService.$insertEntity(
"temp",
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64Data },
["id", "blobB64"]
);
}
logger.debug("Stored shared image in temp database");
} catch (error) {
logger.error("Error storing shared image:", error);
throw error;
}
}
```
#### 3.2 Update main.capacitor.ts
Add check for shared images on app launch:
```typescript
// In main.capacitor.ts, after app mount:
import { checkForNativeSharedImage } from "./services/nativeShareHandler";
// Check for shared images when app becomes active
App.addListener("appStateChange", async (state) => {
if (state.isActive) {
// Check for native shared images
const hasSharedImage = await checkForNativeSharedImage(/* platformService */);
if (hasSharedImage) {
// Navigate to shared-photo view
await router.push({
name: "shared-photo",
query: { source: "native" }
});
}
}
});
// Also check on initial launch
App.getLaunchUrl().then((result) => {
if (result?.url) {
// Handle deep link
} else {
// Check for shared image
checkForNativeSharedImage(/* platformService */).then((hasShared) => {
if (hasShared) {
router.push({ name: "shared-photo", query: { source: "native" } });
}
});
}
});
```
#### 3.3 Update SharedPhotoView.vue
The existing `SharedPhotoView.vue` should work as-is, but we may want to add detection for native vs web sources.
### 4. Alternative Approach: Capacitor Plugin
Instead of implementing native code directly, consider creating a Capacitor plugin:
1. **Create plugin**: `@capacitor-community/share-target` or custom plugin
2. **Plugin methods**:
- `checkForSharedImage()`: Returns shared image data if available
- `clearSharedImage()`: Clears shared image data after processing
This would be cleaner and more maintainable.
### 5. Testing Checklist
- [ ] Test sharing image from Photos app on iOS
- [ ] Test sharing image from Gallery app on Android
- [ ] Test sharing from other apps (Safari, Chrome, etc.)
- [ ] Verify image appears in SharedPhotoView
- [ ] Test "Record Gift" flow with shared image
- [ ] Test "Save as Profile" flow with shared image
- [ ] Test cancel flow
- [ ] Verify temp storage cleanup
- [ ] Test app launch with shared image pending
- [ ] Test app already running when image is shared
### 6. Implementation Priority
**Phase 1: Android (Simpler)**
1. Update AndroidManifest.xml
2. Implement MainActivity intent handling
3. Create JavaScript handler
4. Test end-to-end
**Phase 2: iOS (More Complex)**
1. Create Share Extension target
2. Implement ShareViewController
3. Configure App Groups
4. Create JavaScript handler
5. Test end-to-end
### 7. Notes
- **App Groups (iOS)**: Required for sharing data between Share Extension and main app
- **SharedPreferences (Android)**: Standard way to share data between app components
- **Base64 Encoding**: Both platforms convert images to base64 for JavaScript compatibility
- **File Size Limits**: Consider large image handling and memory management
- **Permissions**: Android 13+ requires `READ_MEDIA_IMAGES` instead of `READ_EXTERNAL_STORAGE`
### 8. References
- [iOS Share Extensions](https://developer.apple.com/documentation/social)
- [Android Share Targets](https://developer.android.com/training/sharing/receive)
- [Capacitor App Plugin](https://capacitorjs.com/docs/apis/app)
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)

View File

@@ -0,0 +1,76 @@
# Xcode 26 / CocoaPods Compatibility Workaround
**Date:** 2025-01-27
**Issue:** CocoaPods `xcodeproj` gem (1.27.0) doesn't support Xcode 26's project format version 70
## The Problem
Xcode 26.1.1 uses project format version 70, but the `xcodeproj` gem (1.27.0) only supports up to version 56. This causes CocoaPods to fail with:
```
ArgumentError - [Xcodeproj] Unable to find compatibility version string for object version `70`.
```
## Solutions
### Option 1: Temporarily Downgrade Project Format (Recommended for Now)
**Before running `pod install` or `npm run build:ios`:**
1. Edit `ios/App/App.xcodeproj/project.pbxproj`
2. Change line 6 from: `objectVersion = 70;` to: `objectVersion = 56;`
3. Run your build/sync command
4. Change it back to: `objectVersion = 70;` (Xcode will likely change it back automatically)
**Warning:** Xcode may automatically upgrade the format back to 70 when you open the project. This is okay - just repeat the process when needed.
### Option 2: Wait for xcodeproj Update
The `xcodeproj` gem maintainers will eventually release a version that supports format 70. You can:
- Check for updates: `bundle update xcodeproj`
- Monitor: https://github.com/CocoaPods/Xcodeproj/issues
### Option 3: Use Xcode Directly (Bypass CocoaPods for Now)
Since the Share Extension is already set up:
1. Open the project in Xcode
2. Build directly from Xcode (Product → Build)
3. Skip `npm run build:ios` for now
4. Test the Share Extension functionality
### Option 4: Automated Workaround (Integrated into Build Script) ✅
The workaround is now **automatically integrated** into `scripts/build-ios.sh`. When you run:
```bash
npm run build:ios
```
The build script will:
1. Automatically detect if the project format is version 70
2. Temporarily downgrade to version 56
3. Run `pod install`
4. Restore to version 70
5. Continue with the build
**No manual steps required!** The workaround is transparent and only applies when needed.
To remove the workaround in the future:
1. Check if `xcodeproj` gem supports format 70: `bundle exec gem list xcodeproj`
2. Test if `pod install` works without the workaround
3. If it works, remove the `run_pod_install_with_workaround()` function from `scripts/build-ios.sh`
4. Replace it with a simple `pod install` call
## Current Status
- ✅ Share Extension target exists
- ✅ Share Extension files are in place
- ✅ Workaround integrated into build script
-`npm run build:ios` works automatically
## Recommendation
**Use `npm run build:ios`** - the workaround is handled automatically. No manual intervention needed.
Once `xcodeproj` is updated to support format 70, the workaround can be removed from the build script.

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -15,8 +15,35 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86585E72ED45A3D00824752 /* ShareImageBridge.swift */; };
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
C86585DD2ED456DE00824752 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = C86585D42ED456DE00824752;
remoteInfo = TimeSafariShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
C86585E02ED456DE00824752 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
@@ -28,10 +55,28 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImageBridge.swift; sourceTree = "<group>"; };
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImagePlugin.swift; sourceTree = "<group>"; };
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -41,6 +86,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D22ED456DE00824752 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -56,6 +108,7 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
504EC3051FED79650016851F /* Products */,
BA325FFCDCE8D334E5C7AEBE /* Pods */,
4B546315E668C7A13939F417 /* Frameworks */,
@@ -66,6 +119,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -73,6 +127,9 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */,
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -108,16 +165,40 @@
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
C86585DE2ED456DE00824752 /* PBXTargetDependency */,
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
C86585D42ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */;
buildPhases = (
C86585D12ED456DE00824752 /* Sources */,
C86585D22ED456DE00824752 /* Frameworks */,
C86585D32ED456DE00824752 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
);
name = TimeSafariShareExtension;
packageProductDependencies = (
);
productName = TimeSafariShareExtension;
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -125,7 +206,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1630;
TargetAttributes = {
504EC3031FED79650016851F = {
@@ -133,6 +214,9 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
C86585D42ED456DE00824752 = {
CreatedOnToolsVersion = 26.1.1;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -149,6 +233,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
);
};
/* End PBXProject section */
@@ -167,6 +252,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D32ED456DE00824752 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -253,12 +345,29 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */,
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D12ED456DE00824752 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
C86585DE2ED456DE00824752 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
targetProxy = C86585DD2ED456DE00824752 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -402,8 +511,9 @@
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 48;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +523,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.8;
MARKETING_VERSION = 1.1.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -429,8 +539,9 @@
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 48;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +551,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.8;
MARKETING_VERSION = 1.1.3;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -450,6 +561,80 @@
};
name = Release;
};
C86585E12ED456DE00824752 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
C86585E22ED456DE00824752 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -471,6 +656,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C86585E12ED456DE00824752 /* Debug */,
C86585E22ED456DE00824752 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari</string>
</array>
</dict>
</plist>

View File

@@ -39,6 +39,33 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Check if this is a shared-photo deep link and store image data in a way JS can access
if url.scheme == "timesafari" && url.host == "shared-photo" {
// Try to get shared image from App Group and store it in a temp file that JS can read
// This is a workaround until the plugin is properly registered
if let sharedData = getSharedImageData() {
// Write to a temp file in the app's Documents directory that JavaScript can read via Filesystem plugin
let fileManager = FileManager.default
if let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
let tempFileURL = documentsDir.appendingPathComponent("timesafari_shared_photo.json")
// Create JSON data
let jsonData: [String: String] = [
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
]
if let json = try? JSONSerialization.data(withJSONObject: jsonData, options: []) {
do {
try json.write(to: tempFileURL)
} catch {
// Error writing temp file - will be handled by JS layer
}
}
}
}
}
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
@@ -50,5 +77,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
/**
* Check for shared image from Share Extension
* Reads from App Group UserDefaults and returns shared image data if available
*
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/
func getSharedImageData() -> [String: String]? {
let appGroupIdentifier = "group.app.timesafari"
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return nil
}
guard let base64 = userDefaults.string(forKey: "sharedPhotoBase64"),
let fileName = userDefaults.string(forKey: "sharedPhotoFileName") else {
return nil
}
// Clear the shared data after reading
userDefaults.removeObject(forKey: "sharedPhotoBase64")
userDefaults.removeObject(forKey: "sharedPhotoFileName")
userDefaults.synchronize()
return ["base64": base64, "fileName": fileName]
}
}

View File

@@ -0,0 +1,48 @@
import Foundation
/**
* Share Image Bridge
*
* Provides a bridge between JavaScript and native iOS code to access
* shared images stored in App Group UserDefaults by the Share Extension.
*
* This bridge allows the JavaScript layer to read shared image data
* that was stored by the Share Extension.
*
* Note: This class doesn't need Capacitor - it's a simple Swift utility
* that reads from App Group UserDefaults. The JavaScript bridge will be
* implemented separately.
*/
@objc(ShareImageBridge)
public class ShareImageBridge: NSObject {
private static let appGroupIdentifier = "group.app.timesafari"
private static let sharedPhotoBase64Key = "sharedPhotoBase64"
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
/**
* Get shared image data from App Group UserDefaults
* Called from JavaScript via Capacitor bridge
*
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/
@objc public static func getSharedImageData() -> [String: String]? {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("ShareImageBridge: Failed to access App Group UserDefaults")
return nil
}
guard let base64 = userDefaults.string(forKey: sharedPhotoBase64Key),
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) else {
return nil
}
// Clear the shared data after reading
userDefaults.removeObject(forKey: sharedPhotoBase64Key)
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
userDefaults.synchronize()
return ["base64": base64, "fileName": fileName]
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
import Capacitor
/**
* Share Image Plugin
*
* Capacitor plugin that exposes ShareImageBridge functionality to JavaScript.
* Allows JavaScript to retrieve shared images from App Group UserDefaults.
*/
@objc(ShareImagePlugin)
public class ShareImagePlugin: CAPPlugin {
@objc func getSharedImageData(_ call: CAPPluginCall) {
guard let sharedData = ShareImageBridge.getSharedImageData() else {
call.resolve(["success": false, "data": NSNull()])
return
}
call.resolve([
"success": true,
"data": [
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
]
])
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,170 @@
//
// ShareViewController.swift
// TimeSafariShareExtension
//
// Created by Aardimus on 11/24/25.
//
import UIKit
import Social
import UniformTypeIdentifiers
class ShareViewController: SLComposeServiceViewController {
private let appGroupIdentifier = "group.app.timesafari"
private let sharedPhotoBase64Key = "sharedPhotoBase64"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
override func viewDidLoad() {
super.viewDidLoad()
// Set placeholder text (required for SLComposeServiceViewController)
self.placeholder = "Share image to TimeSafari"
// Validate content on load
self.validateContent()
}
override func isContentValid() -> Bool {
// Validate that we have image attachments
guard let extensionContext = extensionContext else {
return false
}
guard let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
return false
}
for item in inputItems {
if let attachments = item.attachments {
for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
return true
}
}
}
}
return false
}
override func didSelectPost() {
// Extract and process the shared image
guard let extensionContext = extensionContext else {
return
}
guard let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
return
}
// Process the first image found
processSharedImage(from: inputItems) { [weak self] success in
guard let self = self else {
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
return
}
if success {
// Open the main app via deep link
self.openMainApp()
}
// Complete the extension context
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
}
}
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
// Find the first image attachment
for item in items {
guard let attachments = item.attachments else {
continue
}
for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
guard let self = self else {
completion(false)
return
}
if let error = error {
completion(false)
return
}
// Handle different image data types
var imageData: Data?
var fileName: String = "shared-image.jpg"
if let url = data as? URL {
// Image provided as file URL
imageData = try? Data(contentsOf: url)
fileName = url.lastPathComponent
} else if let image = data as? UIImage {
// Image provided as UIImage
imageData = image.jpegData(compressionQuality: 0.9)
fileName = "shared-image.jpg"
} else if let data = data as? Data {
// Image provided as raw Data
imageData = data
fileName = "shared-image.jpg"
}
guard let finalImageData = imageData else {
completion(false)
return
}
// Convert to base64
let base64String = finalImageData.base64EncodedString()
// Store in App Group UserDefaults
guard let userDefaults = UserDefaults(suiteName: self.appGroupIdentifier) else {
completion(false)
return
}
userDefaults.set(base64String, forKey: self.sharedPhotoBase64Key)
userDefaults.set(fileName, forKey: self.sharedPhotoFileNameKey)
userDefaults.synchronize()
completion(true)
}
return // Process only the first image
}
}
}
// No image found
completion(false)
}
private func openMainApp() {
// Open the main app via deep link
guard let url = URL(string: "timesafari://shared-photo") else {
return
}
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return
}
responder = responder?.next
}
// Fallback: use extension context
extensionContext?.open(url, completionHandler: nil)
}
override func configurationItems() -> [Any]! {
// No additional configuration options needed
return []
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari</string>
</array>
</dict>
</plist>

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.1-beta",
"version": "1.1.4-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.1-beta",
"version": "1.1.4-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
@@ -27,6 +27,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
@@ -6789,6 +6790,25 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz",
"integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.1-beta",
"version": "1.1.4-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"
@@ -156,6 +156,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",

View File

@@ -436,7 +436,21 @@ fi
log_info "Cleaning dist directory..."
clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode
# Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then
@@ -445,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 5: Clean Gradle build
# Step 6: Clean Gradle build
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
# Step 6: Build based on type
# Step 7: Build based on type
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi
# Step 7: Sync with Capacitor
# Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 8: Generate assets
# Step 9: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
# Step 9: Build APK/AAB if requested
# Step 10: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
@@ -474,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
fi
# Step 10: Auto-run app if requested
# Step 11: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android" || {
@@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
log_success "Android app launched successfully!"
fi
# Step 11: Open Android Studio if requested
# Step 12: Open Android Studio if requested
if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
fi

View File

@@ -381,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
log_info "Cleaning dist directory..."
clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode
# Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then
@@ -390,16 +404,149 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 5: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 6: Install CocoaPods dependencies (with Xcode 26 workaround)
# ===================================================================
# WORKAROUND: Xcode 26 / CocoaPods Compatibility Issue
# ===================================================================
# Xcode 26 uses project format version 70, but CocoaPods' xcodeproj gem
# (1.27.0) only supports up to version 56. This causes pod install to fail.
#
# This workaround temporarily downgrades the project format to 56, runs
# pod install, then restores it to 70. Xcode will automatically upgrade
# it back to 70 when opened, which is fine.
#
# NOTE: Both explicit pod install AND Capacitor sync (which runs pod install
# internally) need this workaround. See run_pod_install_with_workaround()
# and run_cap_sync_with_workaround() functions below.
#
# TO REMOVE THIS WORKAROUND IN THE FUTURE:
# 1. Check if xcodeproj gem has been updated: bundle exec gem list xcodeproj
# 2. Test if pod install works without the workaround
# 3. If it works, remove both workaround functions below
# 4. Replace with:
# - safe_execute "Installing CocoaPods dependencies" "cd ios/App && bundle exec pod install && cd ../.." || exit 6
# - safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# 5. Update this comment to indicate the workaround has been removed
# ===================================================================
run_pod_install_with_workaround() {
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
log_info "Installing CocoaPods dependencies (with Xcode 26 workaround)..."
# Check if project file exists
if [ ! -f "$PROJECT_FILE" ]; then
log_error "Project file not found: $PROJECT_FILE"
return 1
fi
# Check current format version
local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
if [ -z "$current_version" ]; then
log_error "Could not determine project format version"
return 1
fi
log_debug "Current project format version: $current_version"
# Only apply workaround if format is 70
if [ "$current_version" = "70" ]; then
log_debug "Applying Xcode 26 workaround: temporarily downgrading to format 56"
# Downgrade to format 56 (supported by CocoaPods)
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
log_error "Failed to downgrade project format"
return 1
fi
# Run pod install
log_info "Running pod install..."
if ! (cd ios/App && bundle exec pod install && cd ../..); then
log_error "pod install failed"
# Try to restore format even on failure
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
return 1
fi
# Restore to format 70
log_debug "Restoring project format to 70..."
if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then
log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)"
fi
log_success "CocoaPods dependencies installed successfully"
else
# Format is not 70, run pod install normally
log_debug "Project format is $current_version, running pod install normally"
if ! (cd ios/App && bundle exec pod install && cd ../..); then
log_error "pod install failed"
return 1
fi
log_success "CocoaPods dependencies installed successfully"
fi
}
# Step 6: Generate assets
safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaround" || exit 6
# Step 6.5: Sync with Capacitor (also needs workaround since it runs pod install internally)
# Capacitor sync internally runs pod install, so we need to apply the workaround here too
run_cap_sync_with_workaround() {
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
# Check current format version
local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
if [ -z "$current_version" ]; then
log_error "Could not determine project format version for Capacitor sync"
return 1
fi
# Only apply workaround if format is 70
if [ "$current_version" = "70" ]; then
log_debug "Applying Xcode 26 workaround for Capacitor sync: temporarily downgrading to format 56"
# Downgrade to format 56 (supported by CocoaPods)
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
log_error "Failed to downgrade project format for Capacitor sync"
return 1
fi
# Run Capacitor sync (which will run pod install internally)
log_info "Running Capacitor sync..."
if ! npx cap sync ios; then
log_error "Capacitor sync failed"
# Try to restore format even on failure
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
return 1
fi
# Restore to format 70
log_debug "Restoring project format to 70 after Capacitor sync..."
if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then
log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)"
fi
log_success "Capacitor sync completed successfully"
else
# Format is not 70, run sync normally
log_debug "Project format is $current_version, running Capacitor sync normally"
if ! npx cap sync ios; then
log_error "Capacitor sync failed"
return 1
fi
log_success "Capacitor sync completed successfully"
fi
}
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 7: Build iOS app
# Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5
# Step 8: Build IPA/App if requested
# Step 9: Build IPA/App if requested
if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..."
cd ios/App
@@ -426,12 +573,12 @@ if [ "$BUILD_APP" = true ]; then
log_success "App bundle built successfully"
fi
# Step 9: Auto-run app if requested
# Step 10: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
fi
# Step 10: Open Xcode if requested
# Step 11: Open Xcode if requested
if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
fi

View File

@@ -38,7 +38,7 @@
}
.dialog {
@apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[90%] overflow-y-auto;
@apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto;
}
/* Markdown content styling to restore list elements */

View File

@@ -111,7 +111,7 @@
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="handleMainAction"
@click="processSelectedMembers"
>
{{ buttonText }}
</button>
@@ -134,8 +134,9 @@ import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { MemberData } from "@/interfaces";
import { setVisibilityUtil, getHeaders } from "@/libs/endorserServer";
import { setVisibilityUtil, getHeaders, register } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
import { Contact } from "@/db/tables/contacts";
@Component({
mixins: [PlatformServiceMixin],
@@ -144,7 +145,7 @@ import { createNotifyHelpers } from "@/utils/notify";
export default class BulkMembersDialog extends Vue {
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
@Prop({ required: true }) dialogType!: "admit" | "visibility";
// isOrganizer: true = organizer mode (admit members), false = member mode (set visibility)
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system
@@ -251,35 +252,35 @@ export default class BulkMembersDialog extends Vue {
return this.selectedMembers.includes(memberDid);
}
async handleMainAction() {
if (this.dialogType === "admit") {
await this.admitWithVisibility();
} else {
await this.addContactWithVisibility();
}
}
async admitWithVisibility() {
async processSelectedMembers() {
try {
const selectedMembers = this.membersData.filter((member) =>
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers = this.membersData.filter(
const notSelectedMembers: MemberData[] = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let admittedCount = 0;
let contactAddedCount = 0;
let errors = 0;
for (const member of selectedMembers) {
try {
// First, admit the member
await this.admitMember(member);
admittedCount++;
// Organizer mode: admit and register the member first
if (this.isOrganizer) {
await this.admitMember(member);
await this.registerMember(member);
admittedCount++;
}
// If they're not a contact yet, add them as a contact
if (!member.isContact) {
await this.addAsContact(member);
// Organizer mode: set isRegistered to true, member mode: undefined
await this.addAsContact(
member,
this.isOrganizer ? true : undefined,
);
contactAddedCount++;
}
@@ -289,85 +290,62 @@ export default class BulkMembersDialog extends Vue {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
errors++;
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
},
10000,
);
if (this.isOrganizer) {
if (admittedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
},
5000,
);
}
if (errors > 0) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to fully admit some members. Work with them individually below.",
},
5000,
);
}
} else {
// Member mode: show contacts added notification
if (contactAddedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added Successfully",
text: `${contactAddedCount} member${contactAddedCount === 1 ? "" : "s"} added as contact${contactAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
}
}
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
console.error(
`Error ${this.isOrganizer ? "admitting members" : "adding contacts"}:`,
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to admit some members. Please try again.",
},
5000,
);
}
}
async addContactWithVisibility() {
try {
const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let contactsAddedCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member);
contactsAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added Successfully",
text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error adding contacts:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to add some members as contacts. Please try again.",
text: "Some errors occurred. Work with members individually below.",
},
5000,
);
@@ -393,11 +371,39 @@ export default class BulkMembersDialog extends Vue {
}
}
async addAsContact(member: { did: string; name: string }) {
async registerMember(member: MemberData) {
try {
const newContact = {
const contact: Contact = { did: member.did };
const result = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (result.success) {
if (result.embeddedRecordError) {
throw new Error(result.embeddedRecordError);
}
await this.$updateContact(member.did, { registered: true });
} else {
throw result;
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error registering member:", err);
throw err;
}
}
async addAsContact(
member: { did: string; name: string },
isRegistered?: boolean,
) {
try {
const newContact: Contact = {
did: member.did,
name: member.name,
registered: isRegistered,
};
await this.$insertContact(newContact);
@@ -440,10 +446,10 @@ export default class BulkMembersDialog extends Vue {
}
showContactInfo() {
const message =
this.dialogType === "admit"
? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet.";
// isOrganizer: true = admit mode, false = visibility mode
const message = this.isOrganizer
? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet.";
this.$notify(
{

View File

@@ -10,7 +10,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
<template>
<div id="sectionDataExport" :class="containerClasses">
<div :class="titleClasses">Data Export</div>
<div :class="titleClasses">Data Management</div>
<router-link
v-if="activeDid"
:to="{ name: 'seed-backup' }"
@@ -30,7 +30,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
:class="exportButtonClasses"
@click="exportDatabase()"
>
{{ isExporting ? "Exporting..." : "Download Contacts" }}
{{ isExporting ? "Exporting..." : "Export Contacts" }}
</button>
<div
@@ -55,11 +55,54 @@ messages * - Conditional UI based on platform capabilities * * @component *
</li>
</ul>
</div>
<!-- Import Contacts -->
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="mt-2">
<input
type="file"
class="w-full bg-white rounded-md pe-2 file:border-0 file:bg-gradient-to-b file:from-blue-400 file:to-blue-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:me-2 file:rounded-s-md"
@change="uploadImportFile"
/>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-2">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md 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-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<button
class="block w-full text-center text-md 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-1.5 py-2 rounded-md"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</transition>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app";
@@ -67,8 +110,10 @@ import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
import { contactsToExportJson } from "../libs/util";
import { createNotifyHelpers } from "@/utils/notify";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { ImportContent } from "@/interfaces/accountView";
/**
* @vue-component
@@ -91,6 +136,12 @@ export default class DataExportSection extends Vue {
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Router instance injected by Vue
* Used for navigation
*/
$router!: Router;
/**
* Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option
@@ -110,6 +161,12 @@ export default class DataExportSection extends Vue {
*/
showRedNotificationDot = false;
/**
* Reference to the selected import file
* Used to store the file selected by the user for import
*/
private inputImportFileName: Blob | undefined;
/**
* Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called
@@ -200,12 +257,30 @@ export default class DataExportSection extends Vue {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
const exContact: Contact = R.omit(["contactMethods"], contact);
// now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
// $contacts() returns normalized contacts where contactMethods is already an array,
// but we handle both array and string cases for robustness
if (contact.contactMethods) {
if (Array.isArray(contact.contactMethods)) {
// Already an array, use it directly
exContact.contactMethods = contact.contactMethods;
} else {
// Check if it's a string that needs parsing (shouldn't happen with normalized contacts, but handle for robustness)
const contactMethodsValue = contact.contactMethods as unknown;
if (
typeof contactMethodsValue === "string" &&
contactMethodsValue.trim() !== ""
) {
// String that needs parsing
exContact.contactMethods = JSON.parse(contactMethodsValue);
} else {
// Invalid data, use empty array
exContact.contactMethods = [];
}
}
} else {
// No contactMethods, use empty array
exContact.contactMethods = [];
}
return exContact;
});
@@ -248,5 +323,58 @@ export default class DataExportSection extends Vue {
this.showRedNotificationDot = false;
}
}
/**
* Handles file selection for contact import
* Stores the selected file for later processing
*/
async uploadImportFile(event: Event): Promise<void> {
this.inputImportFileName = (event.target as HTMLInputElement).files?.[0];
}
/**
* Checks if a contact import file has been selected
* Used to conditionally show the import button
*/
showContactImport(): boolean {
return !!this.inputImportFileName;
}
/**
* Processes the selected import file and navigates to the contact import view
* Parses the JSON file and extracts contact data for import
*/
async checkContactImports(): Promise<void> {
if (!this.inputImportFileName) {
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
this.$router.push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(this.inputImportFileName);
}
}
</script>

View File

@@ -2,12 +2,55 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */
<template>
<ul :class="gridClasses">
<!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
<input
v-model="searchTerm"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
@input="handleSearchInput"
@keydown.enter="performSearch"
/>
<div
v-show="isSearching && searchTerm"
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
>
<font-awesome
icon="spinner"
class="fa-spin-pulse leading-[1.1]"
></font-awesome>
</div>
<button
:disabled="!searchTerm"
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
@click="clearSearch"
>
<font-awesome
:icon="searchTerm ? 'times' : 'magnifying-glass'"
class="fa-fw"
></font-awesome>
</button>
</div>
<div
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
class="mb-4 text-sm italic text-slate-500 text-center"
>
{{ searchTerm }} doesn't match any
{{ entityType === "people" ? "people" : "projects" }}. Try a different
search.
</div>
<ul
ref="scrollContainer"
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
>
<!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'">
<!-- "You" entity -->
<SpecialEntityCard
v-if="showYouEntity"
v-if="showYouEntity && !searchTerm.trim()"
entity-type="you"
label="You"
icon="hand"
@@ -21,6 +64,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity -->
<SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed"
:label="unnamedEntityName"
icon="circle-question"
@@ -32,22 +76,66 @@ projects, and special entities with selection. * * @author Matthew Raymer */
</template>
<!-- Empty state message -->
<li v-if="entities.length === 0" :class="emptyStateClasses">
<li v-if="hasNoEntities" :class="emptyStateClasses">
{{ emptyStateMessage }}
</li>
<!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'">
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
<!-- When showing contacts without search: split into recent and alphabetical -->
<template v-if="!searchTerm.trim()">
<!-- Recently Added Section -->
<template v-if="recentContacts.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 Added
</li>
<PersonCard
v-for="person in recentContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<!-- Alphabetical Section -->
<template v-if="alphabeticalContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone
</li>
<PersonCard
v-for="person in alphabeticalContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<template v-else-if="entityType === 'projects'">
@@ -63,27 +151,30 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected"
/>
</template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { getHeaders } from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { TIMEOUTS } from "@/utils/notify";
/**
* Constants for infinite scroll configuration
*/
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
/**
* EntityGrid - Unified grid layout for displaying people or projects
@@ -93,7 +184,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
* - Special entity integration (You, Unnamed)
* - Conflict detection integration
* - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties
@@ -104,21 +194,49 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
PersonCard,
ProjectCard,
SpecialEntityCard,
ShowAllCard,
},
mixins: [PlatformServiceMixin],
})
export default class EntityGrid extends Vue {
/** Type of entities to display */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Array of entities to display */
@Prop({ required: true })
entities!: Contact[] | PlanData[];
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
// 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;
/**
* Array of entities to display
*
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
* Use $contactsByDateAdded() to ensure all contacts are included.
* Client-side filtering assumes the complete list is available.
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
* (newest first) for the "Recently Added" section to display correctly.
*
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
* projects internally from the API server. If provided, uses the provided list.
*/
@Prop({ required: false })
entities?: Contact[] | PlanData[];
/** Active user's DID */
@Prop({ required: true })
@@ -140,18 +258,14 @@ export default class EntityGrid extends Vue {
@Prop({ default: true })
showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */
@Prop({ default: true })
youSelectable!: boolean;
/** Route name for "Show All" navigation */
@Prop({ default: "" })
showAllRoute!: string;
/** Query parameters for "Show All" navigation */
@Prop({ default: () => ({}) })
showAllQueryParams!: Record<string, string>;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
@@ -160,42 +274,31 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* 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, sorting, and
* display logic beyond the default simple slice behavior.
* 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")
* @param maxItems - The maximum number of items to display (from maxItems prop)
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type, max) =>
* entities.filter(e => e.profileImageUrl).slice(0, max)"
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type, max) =>
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
*
* @example
* // Advanced logic: different limits for different entity types
* :display-entities-function="(entities, type, max) =>
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[];
/**
@@ -206,33 +309,98 @@ export default class EntityGrid extends Vue {
}
/**
* Computed CSS classes for the grid layout
* Check if there are no entities to display
*/
get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
get hasNoEntities(): boolean {
if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
// For projects: check internal state if no entities prop, otherwise check prop
const projectsToCheck = this.entities || this.allProjects;
return projectsToCheck.length === 0;
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
// For people: entities prop is required
return !this.entities || this.entities.length === 0;
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise defaults
* Get the entities array to use (prop or internal state)
*/
get entitiesToUse(): Contact[] | PlanData[] {
if (this.entityType === "projects") {
// For projects: use prop if provided, otherwise use internal state
return this.entities || this.allProjects;
} else {
// For people: entities prop is required
return this.entities || [];
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
*/
get displayedEntities(): Contact[] | PlanData[] {
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(
this.entities,
this.entityType,
this.maxItems,
);
// If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// Default implementation for backward compatibility
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return this.entities.slice(0, maxDisplay);
// 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);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Get the most recently added contacts (when showing contacts and not searching)
*
* NOTE: This assumes entities are already sorted by date added (newest first).
* See the entities prop documentation for details on using $contactsByDateAdded().
*/
get recentContacts(): Contact[] {
if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return [];
}
// Skip the first few (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = this.entities as Contact[];
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
}
/**
@@ -246,15 +414,6 @@ export default class EntityGrid extends Vue {
}
}
/**
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return (
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
);
}
/**
* Whether the "You" entity is conflicted
*/
@@ -328,6 +487,440 @@ export default class EntityGrid extends Vue {
});
}
/**
* Handle search input with debouncing
*/
handleSearchInput(): void {
// Show spinner immediately when user types
this.isSearching = true;
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for 500ms delay
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 500);
}
/**
* Perform the actual search
* Routes to server-side search for projects or client-side filtering for contacts
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
this.searchBeforeId = undefined; // Reset pagination for new search
try {
if (this.entityType === "projects") {
// Server-side search for projects (initial load, no beforeId)
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(undefined, searchLower);
} else {
// Client-side filtering for contacts (complete list)
await this.performContactSearch();
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Fetch projects from API server
* Unified method for both loading all projects and searching projects.
* If claimContents is provided, performs search and updates filteredEntities.
* If claimContents is not provided, loads all projects and updates allProjects.
*
* @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> {
if (!this.apiServer) {
if (claimContents) {
this.filteredEntities = [];
} else {
this.allProjects = [];
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "API server not configured",
},
TIMEOUTS.SHORT,
);
}
return;
}
const isSearch = !!claimContents;
let url = `${this.apiServer}/api/v2/report/plans`;
// Build query parameters
const params: string[] = [];
if (claimContents) {
params.push(
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
);
}
if (beforeId) {
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
}
if (params.length > 0) {
url += `?${params.join("&")}`;
}
try {
const response = await fetch(url, {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error(
isSearch ? "Failed to search projects" : "Failed to load projects",
);
}
const results = await response.json();
if (results.data) {
const newProjects = results.data.map(
(plan: PlanData & { rowId?: string }) => ({
...plan,
rowId: plan.rowId,
}),
);
if (isSearch) {
// Search mode: update filteredEntities
if (beforeId) {
// Pagination: append new projects to existing search results
this.filteredEntities.push(...newProjects);
} else {
// Initial search: replace array
this.filteredEntities = newProjects;
}
// Update searchBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.searchBeforeId = lastProject.rowId || undefined;
} else {
this.searchBeforeId = undefined; // No more results
}
} else {
// Load mode: update allProjects
if (beforeId) {
// Pagination: append new projects
this.allProjects.push(...newProjects);
} else {
// Initial load: replace array
this.allProjects = newProjects;
}
// Update loadBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.loadBeforeId = lastProject.rowId || undefined;
} else {
this.loadBeforeId = undefined; // No more results
}
}
} else {
// No data in response
if (isSearch) {
if (!beforeId) {
// Only clear on initial search, not pagination
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load, not pagination
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
}
} catch (error) {
logger.error(
`Error ${isSearch ? "searching" : "loading"} projects:`,
error,
);
if (isSearch) {
if (!beforeId) {
// Only clear on initial search error, not pagination error
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load error, not pagination error
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: isSearch
? "Failed to search projects. Please try again."
: "Failed to load projects. Please try again.",
},
TIMEOUTS.STANDARD,
);
}
}
}
/**
* Client-side contact search
* Assumes entities prop contains complete contact list from local database
*/
async performContactSearch(): Promise<void> {
if (!this.entities) {
this.filteredEntities = [];
return;
}
// Simulate async (for consistency with project search)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Contacts don't need pagination (complete list)
this.searchBeforeId = undefined;
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// Clear any pending timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
/**
* 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") {
// Projects: can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
const hasMoreLoaded =
this.displayedCount < this.filteredEntities.length;
const canLoadMoreFromServer =
this.displayedCount >= this.filteredEntities.length &&
!!this.searchBeforeId &&
!this.isLoadingSearchMore;
return hasMoreLoaded || canLoadMoreFromServer;
} else {
// Contacts: client-side filtering returns all results at once
return this.displayedCount < this.filteredEntities.length;
}
}
// Non-search mode
if (this.entityType === "projects") {
// Projects: check internal state or prop
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// Can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
const canLoadMoreFromServer =
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
!!beforeId &&
!this.isLoadingProjects;
return hasMoreLoaded || canLoadMoreFromServer;
}
// People: check if more alphabetical contacts available
// Total available = recent + all alphabetical
if (!this.entities) {
return false;
}
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
return this.displayedCount < totalAvailable;
}
/**
* 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 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")
@@ -340,6 +933,47 @@ export default class EntityGrid extends Vue {
} {
return data;
}
/**
* Watch for changes in search term to reset displayed count and pagination
*/
@Watch("searchTerm")
onSearchTermChange(): void {
// Reset displayed count and pagination when search term changes
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to clear search and reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
// Clear search when entities change (fresh dialog open)
if (this.searchTerm) {
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
}
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// For projects: if entities prop is provided, clear internal state
if (this.entityType === "projects" && this.entities) {
this.allProjects = [];
this.loadBeforeId = undefined;
}
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
}
</script>

View File

@@ -3,10 +3,9 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Show All navigation with context preservation * - Cancel functionality * - Event
delegation for entity selection * - Warning notifications for conflicted
entities * - Template streamlined with computed CSS properties * * @author
Matthew Raymer */
Cancel functionality * - Event delegation for entity selection * - Warning
notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
@@ -15,19 +14,15 @@ Matthew Raymer */
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:entities="shouldShowProjects ? projects || undefined : allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify"
:conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
/>
@@ -68,7 +63,6 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display
* - Conflict detection and prevention
* - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
@@ -100,9 +94,9 @@ export default class EntitySelectionStep extends Vue {
@Prop({ default: false })
isFromProjectView!: boolean;
/** Array of available projects */
@Prop({ required: true })
projects!: PlanData[];
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
@Prop({ required: false })
projects?: PlanData[];
/** Array of available contacts */
@Prop({ required: true })
@@ -154,10 +148,6 @@ export default class EntitySelectionStep extends Vue {
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* CSS classes for the cancel button
*/
@@ -222,59 +212,6 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Route name for "Show All" navigation
*/
get showAllRoute(): string {
if (this.shouldShowProjects) {
return "discover";
} else if (this.allContacts.length > 0) {
return "contact-gift";
}
return "";
}
/**
* Query parameters for "Show All" navigation
*/
get showAllQueryParams(): Record<string, string> {
const baseParams = {
stepType: this.stepType,
giverEntityType: this.giverEntityType,
recipientEntityType: this.recipientEntityType,
// Form field values to preserve
description: this.description,
amountInput: this.amountInput,
unitCode: this.unitCode,
offerId: this.offerId,
fromProjectId: this.fromProjectId,
toProjectId: this.toProjectId,
showProjects: this.showProjects.toString(),
isFromProjectView: this.isFromProjectView.toString(),
};
if (this.shouldShowProjects) {
// For project contexts, still pass entity type information
return baseParams;
}
return {
...baseParams,
// Always pass both giver and recipient info for context preservation
giverProjectId: this.fromProjectId || "",
giverProjectName: this.giver?.name || "",
giverProjectImage: this.giver?.image || "",
giverProjectHandleId: this.giver?.handleId || "",
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
recipientProjectId: this.toProjectId || "",
recipientProjectName: this.receiver?.name || "",
recipientProjectImage: this.receiver?.image || "",
recipientProjectHandleId: this.receiver?.handleId || "",
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
};
}
/**
* Handle entity selection from EntityGrid
*/

View File

@@ -15,7 +15,6 @@
giverEntityType === 'project' || recipientEntityType === 'project'
"
:is-from-project-view="isFromProjectView"
:projects="projects"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
@@ -29,7 +28,6 @@
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
@cancel="cancel"
/>
@@ -69,7 +67,6 @@ import {
createAndSubmitGive,
didInfo,
serverMessageForUser,
getHeaders,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { Contact } from "../db/tables/contacts";
@@ -117,7 +114,6 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as
| "person"
| "project";
@@ -136,7 +132,6 @@ export default class GiftedDialog extends Vue {
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
offerId = "";
projects: PlanData[] = [];
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
stepType = "giver";
@@ -233,19 +228,9 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer,
});
this.allContacts = await this.$contacts();
this.allContacts = await this.$contactsByDateAdded();
this.allMyDids = await retrieveAccountDids();
if (
this.giverEntityType === "project" ||
this.recipientEntityType === "project"
) {
await this.loadProjects();
} else {
// Clear projects array when not needed
this.projects = [];
}
} catch (err: unknown) {
logger.error("Error retrieving settings from database:", err);
this.safeNotify.error(
@@ -491,27 +476,6 @@ export default class GiftedDialog extends Vue {
this.firstStep = false;
}
async loadProjects() {
try {
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error("Failed to load projects");
}
const results = await response.json();
if (results.data) {
this.projects = results.data;
}
} catch (error) {
logger.error("Error loading projects:", error);
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
}
}
selectProject(project: PlanData) {
this.giver = {
did: project.handleId,
@@ -519,10 +483,13 @@ export default class GiftedDialog extends Vue {
image: project.image,
handleId: project.handleId,
};
this.receiver = {
did: this.activeDid,
name: "You",
};
// Only set receiver to "You" if no receiver has been selected yet
if (!this.receiver || !this.receiver.did) {
this.receiver = {
did: this.activeDid,
name: "You",
};
}
this.firstStep = false;
}

View File

@@ -0,0 +1,130 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
<!-- EntityGrid for projects -->
<EntityGrid
:entity-type="'projects'"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'project'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md 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 px-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
*
* Features:
* - EntityGrid integration for project selection
* - No special entities (You, Unnamed)
* - Immediate assignment on project selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected project and closes the dialog
*/
handleEntitySelected(event: {
type: "person" | "project";
data: Contact | PlanData;
}) {
const project = event.data as PlanData;
this.emitAssign(project);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
this.emitOpen();
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
this.emitClose();
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(project: PlanData): PlanData {
return project;
}
@Emit("open")
emitOpen(): void {
// Emit when dialog opens
}
@Emit("close")
emitClose(): void {
// Emit when dialog closes
}
}
</script>
<style scoped></style>

View File

@@ -99,7 +99,7 @@
<font-awesome
v-if="member.did === activeDid"
icon="hand"
class="fa-fw text-blue-500"
class="fa-fw text-slate-500"
/>
<font-awesome
v-if="
@@ -113,10 +113,10 @@
</h3>
<div
v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1.5 ms-1"
class="flex items-center gap-1.5 ml-2 ms-1"
>
<button
class="btn-add-contact"
class="btn-add-contact ml-2"
title="Add as contact"
@click="addAsContact(member)"
>
@@ -124,7 +124,7 @@
</button>
<button
class="btn-info-contact"
class="btn-info-contact ml-2"
title="Contact Info"
@click="
informAboutAddingContact(
@@ -135,6 +135,27 @@
<font-awesome icon="circle-info" />
</button>
</div>
<div
v-if="getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1.5 ms-1"
>
<router-link
:to="{ name: 'contact-edit', params: { did: member.did } }"
>
<font-awesome
icon="pen"
class="text-sm text-blue-500 ml-2 mb-1"
/>
</router-link>
<router-link
:to="{ name: 'did', params: { did: member.did } }"
>
<font-awesome
icon="arrow-up-right-from-square"
class="text-sm text-blue-500 ml-2 mb-1"
/>
</router-link>
</div>
</div>
<span
v-if="
@@ -202,7 +223,6 @@
ref="bulkMembersDialog"
:active-did="activeDid"
:api-server="apiServer"
:dialog-type="isOrganizer ? 'admit' : 'visibility'"
:is-organizer="isOrganizer"
@close="closeBulkMembersDialogCallback"
/>

View File

@@ -3,30 +3,25 @@ GiftedDialog.vue to handle person entity display * with selection states and
conflict detection. * * @author Matthew Raymer */
<template>
<li :class="cardClasses" @click="handleClick">
<div class="relative w-fit mx-auto">
<div>
<EntityIcon
v-if="person.did"
:contact="person"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-5xl mb-1"
class="text-slate-400 text-5xl mb-1 shrink-0"
/>
<!-- Time icon overlay for contacts -->
<div
v-if="person.did && showTimeIcon"
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
</div>
</div>
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<div class="overflow-hidden">
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
</div>
</li>
</template>
@@ -81,29 +76,32 @@ export default class PersonCard extends Vue {
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return "opacity-50 cursor-not-allowed";
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return "cursor-pointer hover:bg-slate-50";
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the person name
*/
get nameClasses(): string {
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
const baseNameClasses = "text-sm font-semibold truncate";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
return `${baseNameClasses} text-slate-500`;
}
// Add italic styling for entities without set names
if (!this.person.name) {
return `${baseClasses} italic text-slate-500`;
return `${baseNameClasses} italic text-slate-500`;
}
return baseClasses;
return baseNameClasses;
}
/**

View File

@@ -2,25 +2,26 @@
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer" @click="handleClick">
<div class="relative w-fit mx-auto">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
</div>
<li
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<ProjectIcon
:entity-id="project.handleId"
:icon-size="30"
:image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ project.name || unnamedProject }}
</h3>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
</h3>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ issuerDisplayName }}
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="text-slate-400" />
{{ issuerDisplayName }}
</div>
</div>
</li>
</template>

View File

@@ -0,0 +1,117 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">
Select Representative
</h2>
<!-- EntityGrid for contacts -->
<EntityGrid
:entity-type="'people'"
:entities="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'representative'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md 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 px-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { NotificationIface } from "../constants/app";
/**
* ProjectRepresentativeDialog - Dialog for selecting an authorized representative
*
* Features:
* - EntityGrid integration for contact selection
* - No special entities (You, Unnamed)
* - Immediate assignment on contact selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class ProjectRepresentativeDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Array of available contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected contact and closes the dialog
*/
handleEntitySelected(event: { type: "person" | "project"; data: Contact }) {
const contact = event.data as Contact;
this.emitAssign(contact);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(contact: Contact): Contact {
return contact;
}
}
</script>
<style scoped></style>

View File

@@ -1,66 +0,0 @@
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
entity types. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer">
<router-link :to="navigationRoute" class="block text-center">
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
<h3
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { RouteLocationRaw } from "vue-router";
/**
* ShowAllCard - Displays "Show All" navigation for entity grids
*
* Features:
* - Provides navigation to full entity listings
* - Supports different routes based on entity type
* - Maintains context through query parameters
* - Consistent visual styling with other cards
*/
@Component({ name: "ShowAllCard" })
export default class ShowAllCard extends Vue {
/** Type of entities being shown */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Route name to navigate to */
@Prop({ required: true })
routeName!: string;
/** Query parameters to pass to the route */
@Prop({ default: () => ({}) })
queryParams!: Record<string, string>;
/**
* Computed navigation route with query parameters
*/
get navigationRoute(): RouteLocationRaw {
return {
name: this.routeName,
query: this.queryParams,
};
}
}
</script>
<style scoped>
/* Ensure router-link styling is consistent */
a {
text-decoration: none;
}
a:hover .fa-circle-right {
transform: scale(1.1);
transition: transform 0.2s ease;
}
</style>

View File

@@ -63,23 +63,24 @@ export default class SpecialEntityCard extends Vue {
conflictContext!: string;
/**
* Computed CSS classes for the card container
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseClasses = "block";
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return `${baseClasses} cursor-not-allowed opacity-50`;
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return `${baseClasses} cursor-pointer`;
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the icon
*/
get iconClasses(): string {
const baseClasses = "text-5xl mb-1";
const baseClasses = "text-[2rem]";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
@@ -101,7 +102,7 @@ export default class SpecialEntityCard extends Vue {
*/
get nameClasses(): string {
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
"text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;

29
src/constants/contacts.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Constants for contact-related functionality
* Created: 2025-11-16
*/
/**
* Contact method types with user-friendly labels
* Used in: ContactEditView.vue, DIDView.vue
*/
export const CONTACT_METHOD_TYPES = [
{ value: "CELL", label: "Mobile" },
{ value: "EMAIL", label: "Email" },
{ value: "WHATSAPP", label: "WhatsApp" },
] as const;
/**
* Type for contact method type values
*/
export type ContactMethodType = (typeof CONTACT_METHOD_TYPES)[number]["value"];
/**
* Helper function to get label for a contact method type
* @param type - The contact method type value (e.g., "CELL", "EMAIL")
* @returns The user-friendly label or the original type if not found
*/
export function getContactMethodLabel(type: string): string {
const methodType = CONTACT_METHOD_TYPES.find((m) => m.value === type);
return methodType ? methodType.label : type;
}

View File

@@ -510,14 +510,6 @@ export const NOTIFY_REGISTER_CONTACT = {
text: "Do you want to register them?",
};
// Used in: ContactsView.vue (showOnboardMeetingDialog method - complex modal for onboarding meeting)
export const NOTIFY_ONBOARDING_MEETING = {
title: "Onboarding Meeting",
text: "Would you like to start a new meeting?",
yesText: "Start New Meeting",
noText: "Join Existing Meeting",
};
// TestView.vue specific constants
// Used in: TestView.vue (executeSql method - SQL error handling)
export const NOTIFY_SQL_ERROR = {

View File

@@ -70,15 +70,6 @@ export interface AxiosErrorResponse {
[key: string]: unknown;
}
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
export interface CreateAndSubmitClaimResult {
success: boolean;
embeddedRecordError?: string;

View File

@@ -68,6 +68,7 @@ export const deepLinkPathSchemas = {
"user-profile": z.object({
id: z.string(),
}),
"shared-photo": z.object({}),
};
export const deepLinkQuerySchemas = {

View File

@@ -4,3 +4,4 @@ export * from "./common";
export * from "./deepLinks";
export * from "./limits";
export * from "./records";
export * from "./user";

View File

@@ -57,7 +57,12 @@ export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string;
}
// a summary record; the VC is not currently part of this record
/**
* A summary record
* The VC is not currently part of this record.
*
* If you change this, you may want to update NewActivityView.vue to handle differences correctly.
*/
export interface PlanSummaryRecord {
agentDid?: string;
description: string;
@@ -76,7 +81,9 @@ export interface PlanSummaryRecord {
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
// This can be undefined, eg. if a project is starred after the stored last-seen-change-jwt ID.
// The endorser-ch test code shows some cases.
wrappedClaimBefore?: GenericCredWrapper<PlanActionClaim>;
}
/**

View File

@@ -42,9 +42,6 @@ import {
PlanActionClaim,
RegisterActionClaim,
TenureClaim,
} from "../interfaces/claims";
import {
GenericCredWrapper,
GenericVerifiableCredential,
AxiosErrorResponse,
@@ -55,13 +52,11 @@ import {
QuantitativeValue,
KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import {
OfferSummaryRecord,
OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records";
} from "../interfaces";
import { logger, safeStringify } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { APP_SERVER } from "@/constants/app";
@@ -1662,31 +1657,39 @@ export async function register(
message?: string;
}>(url, { jwtEncoded: vcJwt });
if (resp.data?.success?.handleId) {
return { success: true };
} else if (resp.data?.success?.embeddedRecordError) {
if (resp.data?.success?.embeddedRecordError) {
let message =
"There was some problem with the registration and so it may not be complete.";
if (typeof resp.data.success.embeddedRecordError === "string") {
message += " " + resp.data.success.embeddedRecordError;
}
return { error: message };
} else if (resp.data?.success?.handleId) {
return { success: true };
} else {
logger.error("Registration error:", JSON.stringify(resp.data));
return { error: "Got a server error when registering." };
logger.error("Registration non-thrown error:", JSON.stringify(resp.data));
return {
error:
(resp.data?.error as { message?: string })?.message ||
(resp.data?.error as string) ||
"Got a server error when registering.",
};
}
} catch (error: unknown) {
if (error && typeof error === "object") {
const err = error as AxiosErrorResponse;
const errorMessage =
err.message ||
(err.response?.data &&
typeof err.response.data === "object" &&
"message" in err.response.data
? (err.response.data as { message: string }).message
: undefined);
logger.error("Registration error:", errorMessage || JSON.stringify(err));
return { error: errorMessage || "Got a server error when registering." };
err.response?.data?.error?.message ||
err.response?.data?.error ||
err.message;
logger.error(
"Registration thrown error:",
errorMessage || JSON.stringify(err),
);
return {
error:
(errorMessage as string) || "Got a server error when registering.",
};
}
return { error: "Got a server error when registering." };
}

View File

@@ -43,6 +43,7 @@ import {
faDownload,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText,
faEraser,
faEye,
@@ -101,6 +102,9 @@ import {
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
// Brand icons
import { faWhatsapp } from "@fortawesome/free-brands-svg-icons";
// Initialize Font Awesome library with all required icons
library.add(
faArrowDown,
@@ -140,6 +144,7 @@ library.add(
faDownload,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText,
faEraser,
faEye,
@@ -193,6 +198,7 @@ library.add(
faTriangleExclamation,
faUser,
faUsers,
faWhatsapp,
faXmark,
);

View File

@@ -30,11 +30,15 @@
import { initializeApp } from "./main.common";
import { App as CapacitorApp } from "@capacitor/app";
import { Capacitor } from "@capacitor/core";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import router from "./router";
import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logger, safeStringify } from "./utils/logger";
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
import { SHARED_PHOTO_BASE64_KEY } from "./libs/util";
import "./utils/safeAreaInset";
logger.log("[Capacitor] 🚀 Starting initialization");
@@ -67,11 +71,220 @@ const deepLinkHandler = new DeepLinkHandler(router);
*
* @throws {Error} If URL format is invalid
*/
/**
* Check for native shared image from iOS App Group UserDefaults
* and store in temp database before routing to shared-photo view
*/
async function checkAndStoreNativeSharedImage(): Promise<{
success: boolean;
fileName?: string;
}> {
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") {
return { success: false };
}
try {
logger.debug("[Main] Checking for iOS shared image from App Group");
// Use Capacitor's native bridge to call the ShareImagePlugin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const capacitor = (window as any).Capacitor;
if (!capacitor || !capacitor.Plugins) {
logger.debug("[Main] Capacitor plugins not available");
return { success: false };
}
// WORKAROUND: Since the plugin isn't being auto-discovered, use a temp file bridge
// AppDelegate writes the shared image data to a temp file, and we read it here
try {
const tempFilePath = "timesafari_shared_photo.json";
const fileContent = await Filesystem.readFile({
path: tempFilePath,
directory: Directory.Documents,
encoding: Encoding.UTF8,
});
if (fileContent.data) {
const sharedData = JSON.parse(fileContent.data as string);
const base64 = sharedData.base64;
const fileName = sharedData.fileName || "shared-image.jpg";
if (base64) {
// Store in temp database using dbExec directly
logger.info(
"[Main] Native shared image found (via temp file), storing in temp DB",
);
const platformService = PlatformServiceFactory.getInstance();
// Convert raw base64 to data URL format that base64ToBlob expects
// base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..."
// Try to detect image type from base64 or default to jpeg
let mimeType = "image/jpeg"; // default
if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) {
// JPEG or PNG
mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
}
const dataUrl = `data:${mimeType};base64,${base64}`;
// Use INSERT OR REPLACE to handle existing records
await platformService.dbExec(
"INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)",
[SHARED_PHOTO_BASE64_KEY, dataUrl],
);
// Delete the temp file
try {
await Filesystem.deleteFile({
path: tempFilePath,
directory: Directory.Documents,
});
} catch (deleteError) {
logger.error("[Main] Failed to delete temp file:", deleteError);
}
logger.info(`[Main] Stored shared image: ${fileName}`);
return { success: true, fileName };
}
}
} catch (fileError: unknown) {
// File doesn't exist or can't be read - that's okay, try plugin method
logger.debug(
"[Main] Temp file not found or unreadable (this is normal if plugin works)",
);
}
// NOTE: Plugin registration issue - ShareImage plugin is not being auto-discovered
// This is a known issue that needs to be resolved. For now, we use the temp file workaround above.
// Try multiple methods to call the plugin (fallback if temp file method fails)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const plugins = (capacitor as any).Plugins;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const shareImagePlugin = (plugins as any)?.ShareImage;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: any = null;
if (
shareImagePlugin &&
typeof shareImagePlugin.getSharedImageData === "function"
) {
logger.debug("[Main] Using direct plugin method");
try {
result = await shareImagePlugin.getSharedImageData();
} catch (pluginError) {
logger.error("[Main] Plugin call failed:", pluginError);
return { success: false };
}
} else {
// Method 2: Use Capacitor's execute method
logger.debug(
"[Main] Plugin not found directly, trying Capacitor.execute",
);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bridge = (capacitor as any).getBridge?.();
if (bridge) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await bridge.execute({
pluginId: "ShareImage",
methodName: "getSharedImageData",
options: {},
});
} else {
// Method 3: Try execute on Plugins object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((plugins as any)?.execute) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await (plugins as any).execute(
"ShareImage",
"getSharedImageData",
{},
);
}
}
} catch (executeError) {
logger.error("[Main] Execute method failed:", executeError);
return { success: false };
}
}
if (!result || !result.success || !result.data) {
logger.debug("[Main] No shared image data found in result");
return { success: false };
}
const { base64, fileName } = result.data;
if (!base64) {
logger.debug("[Main] Shared image data missing base64");
return { success: false };
}
logger.info("[Main] Native shared image found, storing in temp DB");
// Store in temp database (similar to web flow)
const platformService = PlatformServiceFactory.getInstance();
// $insertEntity is available via PlatformServiceMixin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (platformService as any).$insertEntity(
"temp",
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64 },
["id", "blobB64"],
);
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
return { success: true, fileName: fileName || "shared-image.jpg" };
} catch (error) {
logger.error("[Main] Error checking for native shared image:", error);
return { success: false };
}
}
const handleDeepLink = async (data: { url: string }) => {
const { url } = data;
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
try {
// Check if this is a shared-photo deep link from native share
const isSharedPhotoLink = url.includes("timesafari://shared-photo");
if (
isSharedPhotoLink &&
Capacitor.isNativePlatform() &&
Capacitor.getPlatform() === "ios"
) {
logger.debug(
"[Main] 📸 Shared photo deep link detected, checking for native shared image",
);
// Try to get shared image from App Group and store in temp database
try {
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info("[Main] ✅ Native shared image stored in temp database");
// Add fileName to the URL as a query parameter if we have it
if (imageResult.fileName) {
const urlObj = new URL(url);
urlObj.searchParams.set("fileName", imageResult.fileName);
const modifiedUrl = urlObj.toString();
data.url = modifiedUrl;
logger.debug(`[Main] Added fileName to URL: ${modifiedUrl}`);
}
} else {
logger.debug(
"[Main] No native shared image found (may be from web or already processed)",
);
}
} catch (error) {
logger.error("[Main] Error processing native shared image:", error);
// Continue with normal deep link processing even if native check fails
}
}
// Wait for router to be ready
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
await router.isReady();

View File

@@ -91,16 +91,92 @@ export class CapacitorPlatformService
}
try {
// Create/Open database
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
// Try to create/Open database connection
try {
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
} catch (createError: unknown) {
// If connection already exists, try to retrieve it or handle gracefully
const errorMessage =
createError instanceof Error
? createError.message
: String(createError);
const errorObj =
typeof createError === "object" && createError !== null
? (createError as { errorMessage?: string; message?: string })
: {};
await this.db.open();
const fullErrorMessage =
errorObj.errorMessage || errorObj.message || errorMessage;
if (fullErrorMessage.includes("already exists")) {
logger.debug(
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
);
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(
this.dbName,
false,
);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
);
} else {
// Connection exists on native side but not in JavaScript Map
// This can happen when the app is restarted but native connections persist
// Try to close the native connection first, then create a new one
logger.debug(
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
);
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
"[CapacitorPlatformService] Error closing connection (may be expected):",
closeError,
);
}
// Now try to create the connection again
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
logger.debug(
"[CapacitorPlatformService] Successfully created connection after cleanup",
);
}
} else {
// Re-throw if it's a different error
throw createError;
}
}
// Open the connection if it's not already open
try {
await this.db.open();
} catch (openError: unknown) {
const openErrorMessage =
openError instanceof Error ? openError.message : String(openError);
// If already open, that's fine - continue
if (!openErrorMessage.includes("already open")) {
throw openError;
}
logger.debug(
"[CapacitorPlatformService] Database connection already open",
);
}
// Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;");

View File

@@ -19,7 +19,6 @@
<EntityGrid
entity-type="people"
:entities="people"
:max-items="5"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -39,7 +38,6 @@
<EntityGrid
entity-type="projects"
:entities="projects"
:max-items="3"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -152,11 +150,8 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = (
entities: Contact[],
_entityType: string,
maxItems: number,
): Contact[] => {
return entities
.filter((person) => person.profileImageUrl)
.slice(0, maxItems);
return entities.filter((person) => person.profileImageUrl);
};
/**
@@ -165,7 +160,6 @@ export default class EntityGridFunctionPropTest extends Vue {
customProjectsFunction = (
entities: PlanData[],
_entityType: string,
_maxItems: number,
): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
};
@@ -200,16 +194,16 @@ export default class EntityGridFunctionPropTest extends Vue {
*/
get displayedPeopleCount(): number {
if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people", 5).length;
return this.customPeopleFunction(this.people, "people").length;
}
return Math.min(5, this.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", 3).length;
return this.customProjectsFunction(this.projects, "projects").length;
}
return Math.min(7, this.projects.length);
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
}
}
</script>

View File

@@ -970,6 +970,20 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts);
},
/**
* Load all contacts sorted by when they were added (by ID)
* Always fetches fresh data from database for consistency
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
*/
async $contactsByDateAdded(): Promise<Contact[]> {
const rawContacts = (await this.$query(
"SELECT * FROM contacts ORDER BY id DESC",
)) as ContactMaybeWithJsonStrings[];
return this.$normalizeContacts(rawContacts);
},
/**
* Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total number of contacts
@@ -1353,6 +1367,9 @@ export const PlatformServiceMixin = {
contact.profileImageUrl !== undefined
? contact.profileImageUrl
: null,
notes: contact.notes !== undefined ? contact.notes : null,
iViewContent:
contact.iViewContent !== undefined ? contact.iViewContent : null,
contactMethods:
contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods)
@@ -1363,8 +1380,8 @@ export const PlatformServiceMixin = {
await this.$dbExec(
`INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
safeContact.did,
safeContact.name,
@@ -1373,6 +1390,8 @@ export const PlatformServiceMixin = {
safeContact.registered,
safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.contactMethods,
],
);
@@ -2057,6 +2076,7 @@ declare module "@vue/runtime-core" {
// Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;

View File

@@ -375,45 +375,6 @@
Switch Identifier
</router-link>
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="ml-4 mt-2">
<input type="file" class="ml-2" @change="uploadImportFile" />
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-4">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md 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-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<div class="flex justify-center">
<button
class="block text-center text-md 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-1.5 py-2 rounded-md mb-6"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</div>
</transition>
</div>
</div>
<label
for="toggleShowAmounts"
class="flex items-center justify-between cursor-pointer my-4"
@@ -770,9 +731,7 @@ import "dexie-export-import";
import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as L from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
@@ -799,7 +758,6 @@ import {
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
BoundingBox,
@@ -823,11 +781,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
AccountSettings,
isApiError,
ImportContent,
} from "@/interfaces/accountView";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
// Profile data interface (inlined from ProfileService)
interface ProfileData {
description: string;
@@ -836,8 +790,6 @@ interface ProfileData {
includeLocation: boolean;
}
const inputImportFileNameRef = ref<Blob>();
interface UserNameDialogRef {
open: (cb: (name?: string) => void) => void;
}
@@ -1369,65 +1321,6 @@ export default class AccountViewView extends Vue {
);
}
async uploadImportFile(event: Event): Promise<void> {
inputImportFileNameRef.value = (
event.target as HTMLInputElement
).files?.[0];
}
showContactImport(): boolean {
return !!inputImportFileNameRef.value;
}
confirmSubmitImportFile(): void {
if (inputImportFileNameRef.value != null) {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
this.submitImportFile,
);
}
}
/**
* Asynchronously imports the database from a downloadable JSON file.
*
* @throws Will notify the user if there is an export error.
*/
async submitImportFile(): Promise<void> {
if (inputImportFileNameRef.value != null) {
// TODO: implement this for SQLite
}
}
async checkContactImports(): Promise<void> {
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
(this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(inputImportFileNameRef.value as Blob);
}
private progressCallback(progress: ImportProgress): boolean {
logger.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,

View File

@@ -55,66 +55,70 @@
<!-- Contact Methods -->
<div class="mt-4">
<h2 class="text-lg font-medium text-gray-700">Contact Methods</h2>
<div
v-for="(method, index) in contactMethods"
:key="index"
class="flex mt-2"
>
<input
v-model="method.label"
type="text"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
v-model="method.type"
type="text"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
class="px-2 py-1 bg-gray-200 rounded-md"
@click="toggleDropdown(index)"
>
<font-awesome icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'CELL')"
<div v-for="(method, index) in contactMethods" :key="index" class="mt-4">
<!-- Type and Value Row -->
<div class="flex gap-2">
<div class="flex-none w-32">
<label class="block text-xs font-medium text-gray-700 mb-1">
Type
</label>
<select
v-model="method.type"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
>
CELL
</div>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'EMAIL')"
>
EMAIL
</div>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'WHATSAPP')"
>
WHATSAPP
</div>
<option value=""></option>
<option
v-for="methodType in contactMethodTypes"
:key="methodType.value"
:value="methodType.value"
>
{{ methodType.label }}
</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-700 mb-1">
Value
</label>
<input
v-model="method.value"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
</div>
<button
class="self-end pb-0.5 text-red-500"
@click="removeContactMethod(index)"
>
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<!-- WhatsApp Help Text -->
<div
v-if="method.type === 'WHATSAPP'"
class="mt-1 ml-[calc(8rem+0.5rem)] text-xs text-gray-600 italic"
>
Must include country code and only numbers (e.g., 12225551234)
</div>
<!-- Label Row -->
<div class="mt-2 flex justify-end">
<div class="flex-1 ml-[calc(8rem+0.5rem)]">
<label class="block text-xs font-medium text-gray-700 mb-1">
</label>
<input
v-model="method.label"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label / Note"
/>
</div>
<div class="w-[2.5rem]"></div>
</div>
<input
v-model="method.value"
type="text"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button class="ml-2 text-red-500" @click="removeContactMethod(index)">
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<button class="mt-2" @click="addContactMethod">
<button class="mt-4" @click="addContactMethod">
<font-awesome
icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
@@ -157,6 +161,7 @@ import {
} from "../constants/notifications";
import { Contact, ContactMethod } from "../db/tables/contacts";
import { AppString } from "../constants/app";
import { CONTACT_METHOD_TYPES } from "../constants/contacts";
/**
* Contact Edit View Component
@@ -219,11 +224,11 @@ export default class ContactEditView extends Vue {
contactNotes = "";
/** Array of editable contact methods */
contactMethods: Array<ContactMethod> = [];
/** Currently open dropdown index, null if none open */
dropdownIndex: number | null = null;
/** App string constants */
AppString = AppString;
/** Contact method types for datalist suggestions */
contactMethodTypes = CONTACT_METHOD_TYPES;
/**
* Component lifecycle hook that initializes the contact edit form
@@ -280,29 +285,6 @@ export default class ContactEditView extends Vue {
this.contactMethods.splice(index, 1);
}
/**
* Toggles the type selection dropdown for a contact method
*
* If the clicked dropdown is already open, closes it.
* If another dropdown is open, closes it and opens the clicked one.
*
* @param index The array index of the method whose dropdown to toggle
*/
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
/**
* Sets the type for a contact method and closes the dropdown
*
* @param index The array index of the method to update
* @param type The new type value (CELL, EMAIL, WHATSAPP)
*/
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
/**
* Saves the edited contact information to the database
*
@@ -338,17 +320,16 @@ export default class ContactEditView extends Vue {
}
// Save to database via PlatformServiceMixin
// Normalize empty strings to null to preserve database consistency
await this.$updateContact(this.contact?.did || "", {
name: this.contactName,
notes: this.contactNotes,
name: this.contactName?.trim() || null,
notes: this.contactNotes?.trim() || null,
contactMethods: contactMethods,
});
// Notify success and redirect
this.notify.success(NOTIFY_CONTACT_SAVED.message, TIMEOUTS.STANDARD);
(this.$router as Router).push({
path: "/did/" + encodeURIComponent(this.contact?.did || ""),
});
this.$router.back();
}
}
</script>

View File

@@ -171,9 +171,11 @@ import {
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
} from "../libs/endorserServer";
import { GiveSummaryRecord } from "@/interfaces/records";
import { UserInfo } from "@/interfaces/common";
import { VerifiableCredential } from "@/interfaces/claims-result";
import {
GiveSummaryRecord,
UserInfo,
VerifiableCredential,
} from "@/interfaces";
import * as libsUtil from "../libs/util";
import {
generateSaveAndActivateIdentity,

View File

@@ -12,12 +12,12 @@
</h1>
<!-- Back -->
<router-link
<button
class="order-first text-lg text-center leading-none p-1"
:to="{ name: 'contacts' }"
@click="goBack()"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</router-link>
</button>
<!-- Help button -->
<router-link
@@ -42,6 +42,58 @@
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</router-link>
</h2>
<!-- Notes -->
<div v-if="contactFromDid.notes" class="mt-3">
<p class="text-sm text-slate-700 whitespace-pre-wrap">
{{ contactFromDid.notes }}
</p>
</div>
<!-- Contact Methods -->
<div v-if="contactFromDid.contactMethods?.length" class="mt-3">
<div class="flex flex-col gap-2">
<div
v-for="(method, index) in contactFromDid.contactMethods"
:key="index"
class="flex items-center gap-2 text-sm"
>
<span class="font-semibold text-slate-600"
>{{
getContactMethodLabel(method.type) || "(unspecified)"
}}:</span
>
<span class="text-slate-700">{{ method.label }}</span>
<span class="text-slate-600">{{ method.value }}</span>
<a
v-if="method.type === 'CELL'"
:href="`sms:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send text message"
>
<font-awesome icon="message" class="text-base" />
</a>
<a
v-if="method.type === 'EMAIL'"
:href="`mailto:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send email"
>
<font-awesome icon="envelope" class="text-base" />
</a>
<a
v-if="method.type === 'WHATSAPP'"
:href="`https://wa.me/${method.value.replace(/\D/g, '')}`"
target="_blank"
class="ml-2 text-blue-700"
title="Send WhatsApp message"
>
<font-awesome :icon="['fab', 'whatsapp']" class="text-base" />
</a>
</div>
</div>
</div>
<button class="ml-2 mr-2 mt-4" @click="toggleDidDetails">
Details
<font-awesome
@@ -302,6 +354,7 @@ import {
NOTIFY_CONTACT_INVALID_DID,
} from "@/constants/notifications";
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
import { getContactMethodLabel } from "@/constants/contacts";
/**
* DIDView Component
@@ -352,6 +405,7 @@ export default class DIDView extends Vue {
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
didInfoForContact = didInfoForContact;
displayAmount = displayAmount;
getContactMethodLabel = getContactMethodLabel;
/**
* Initializes notification helpers
@@ -476,7 +530,7 @@ export default class DIDView extends Vue {
* Navigation helper methods
*/
goBack() {
this.$router.go(-1);
this.$router.back();
}
/**

View File

@@ -898,7 +898,13 @@ export default class HomeView extends Vue {
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
// filter out any data elements where there is no wrappedClaimBefore
const filteredNewStarredProjectChanges =
starredProjectChanges.data.filter(
(change) => change.wrappedClaimBefore !== undefined,
);
this.numNewStarredProjectChanges =
filteredNewStarredProjectChanges.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature

View File

@@ -284,7 +284,10 @@
</table>
</div>
</div>
<div v-else>The changes did not affect essential project data.</div>
<div v-else>
The changes are not important, like it was saved by accident or
you've seen it all before.
</div>
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@@ -589,13 +592,13 @@ export default class NewActivityView extends Vue {
for (const planChange of planChanges) {
const currentPlan: PlanSummaryRecord = planChange.plan;
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
const wrappedClaim: GenericCredWrapper<PlanActionClaim> | undefined =
planChange.wrappedClaimBefore;
// Extract the actual claim from the wrapped claim
let previousClaim: PlanActionClaim;
let previousClaim: PlanActionClaim | undefined;
const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
const embeddedClaim: PlanActionClaim | undefined = wrappedClaim?.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
@@ -609,7 +612,9 @@ export default class NewActivityView extends Vue {
previousClaim = embeddedClaim;
}
if (!previousClaim || !currentPlan.handleId) {
if (!previousClaim) {
// Can happen when a project is starred after the stored last-seen-change-jwt ID
// so we'll just leave the message saying there are no important differences.
continue;
}

View File

@@ -60,12 +60,60 @@
</div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<input
v-model="agentDid"
type="text"
placeholder="Other Authorized Representative"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
<!-- Authorized Representative Selection -->
<div class="w-full flex items-stretch my-4">
<div
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
@click="openRepresentativeDialog"
>
<div>
<EntityIcon
v-if="selectedRepresentative"
:contact="selectedRepresentative"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome v-else icon="user" class="text-slate-400" />
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedRepresentative,
'text-slate-400': !selectedRepresentative,
}"
class="truncate"
>
{{
selectedRepresentative
? selectedRepresentative.name || AppString.NO_CONTACT_NAME
: "Assign Authorized Representative…"
}}
</div>
<div
v-if="selectedRepresentative"
class="text-xs text-slate-500 truncate"
>
{{ agentDid }}
</div>
</div>
</div>
<button
v-if="selectedRepresentative"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetRepresentative"
>
<font-awesome icon="trash-can" />
</button>
</div>
<ProjectRepresentativeDialog
ref="representativeDialog"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:notify="$notify"
@assign="handleRepresentativeAssigned"
/>
<div class="mb-4">
<p v-if="shouldShowOwnershipWarning">
<span class="text-red-500">Beware!</span>
@@ -232,9 +280,12 @@ import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { LeafletMouseEvent } from "leaflet";
import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import {
AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
@@ -268,6 +319,7 @@ import {
retrieveAccountCount,
retrieveFullyDecryptedAccount,
} from "../libs/util";
import { Contact } from "../db/tables/contacts";
import {
EventTemplate,
@@ -323,7 +375,15 @@ import { logger } from "../utils/logger";
*/
@Component({
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
components: {
EntityIcon,
ImageMethodDialog,
ProjectRepresentativeDialog,
LMap,
LMarker,
LTileLayer,
QuickNav,
},
mixins: [PlatformServiceMixin],
})
export default class NewEditProjectView extends Vue {
@@ -334,6 +394,9 @@ export default class NewEditProjectView extends Vue {
// Notification helpers
private notify!: ReturnType<typeof createNotifyHelpers>;
// Constants
AppString = AppString;
/**
* Display error notification to user
* Provides consistent error messaging with 5-second timeout
@@ -346,6 +409,8 @@ export default class NewEditProjectView extends Vue {
// Component state properties
activeDid = "";
agentDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
apiServer = "";
endDateInput?: string;
endTimeInput?: string;
@@ -392,16 +457,24 @@ export default class NewEditProjectView extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
// Get all user's DIDs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
// Load contacts sorted by date added (newest first) for consistent "Recently Added" display
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId = (this.$route.query["projectId"] as string) || "";
if (this.projectId) {
if (this.isSavedProject()) {
if (this.numAccounts === 0) {
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
} else {
this.loadProject(this.activeDid);
this.loadProject(this.activeDid, this.projectId);
}
}
}
@@ -411,11 +484,9 @@ export default class NewEditProjectView extends Vue {
* Retrieves project information from the API and populates form fields
* @param userDid - User's decentralized identifier
*/
async loadProject(userDid: string) {
async loadProject(userDid: string, projectId: string) {
const url =
this.apiServer +
"/api/claim/byHandle/" +
encodeURIComponent(this.projectId);
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
const headers = await getHeaders(userDid);
try {
@@ -432,6 +503,12 @@ export default class NewEditProjectView extends Vue {
}
if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier;
if (this.activeDid !== this.projectIssuerDid) {
this.agentDid = this.projectIssuerDid;
this.notify.warning(
"You were previously the agent, so the agent has been set to the previous owner. You can change it.",
);
}
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
@@ -536,7 +613,7 @@ export default class NewEditProjectView extends Vue {
private async saveProject() {
// Make a claim
const vcClaim: PlanActionClaim = this.fullClaim;
if (this.projectId) {
if (this.isSavedProject()) {
vcClaim.lastClaimId = this.lastClaimJwtId;
}
if (this.agentDid) {
@@ -870,6 +947,10 @@ export default class NewEditProjectView extends Vue {
this.longitude = event.latlng.lng;
}
private isSavedProject(): boolean {
return !!this.projectId;
}
/**
* Computed property for character count display
* Shows current description length and maximum character limit
@@ -885,6 +966,7 @@ export default class NewEditProjectView extends Vue {
*/
get shouldShowOwnershipWarning(): boolean {
return (
this.isSavedProject() &&
this.activeDid !== this.projectIssuerDid &&
this.agentDid !== this.projectIssuerDid
);
@@ -961,5 +1043,37 @@ export default class NewEditProjectView extends Vue {
get shouldShowSpinner(): boolean {
return !this.isHiddenSpinner;
}
/**
* Computed property for selected representative contact
* Derives the contact from agentDid by finding it in allContacts
*/
get selectedRepresentative(): Contact | null {
if (!this.agentDid) {
return null;
}
return this.allContacts.find((c) => c.did === this.agentDid) || null;
}
/**
* Open the representative selection dialog
*/
openRepresentativeDialog(): void {
(this.$refs.representativeDialog as ProjectRepresentativeDialog).open();
}
/**
* Handle representative assignment from dialog
*/
handleRepresentativeAssigned(contact: Contact): void {
this.agentDid = contact.did;
}
/**
* Unset the representative and revert to initial state
*/
unsetRepresentative(): void {
this.agentDid = "";
}
}
</script>

View File

@@ -186,16 +186,59 @@
<div>
<label
for="projectLink"
class="block text-sm font-medium text-gray-700"
class="block text-sm font-medium text-gray-700 mb-1"
>Project Link</label
>
<input
id="projectLink"
v-model="newOrUpdatedMeetingInputs.projectLink"
type="text"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Project ID"
/>
<div class="w-full flex items-stretch">
<div
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
@click="openProjectLinkDialog"
>
<div>
<ProjectIcon
v-if="selectedProject"
:entity-id="selectedProject.handleId"
:icon-size="30"
:image-url="selectedProject.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="folder-open"
class="text-slate-400"
/>
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedProject,
'text-slate-400': !selectedProject,
}"
class="truncate"
>
{{
selectedProject
? selectedProject.name || "Unnamed Project"
: "Select Project…"
}}
</div>
<div
v-if="selectedProject"
class="text-xs text-slate-500 truncate"
>
<font-awesome icon="user" class="text-slate-400" />
{{ selectedProjectIssuerName }}
</div>
</div>
</div>
<button
v-if="selectedProject"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetProjectLink"
>
<font-awesome icon="trash-can" />
</button>
</div>
</div>
<button
@@ -224,6 +267,17 @@
</form>
</div>
<MeetingProjectDialog
ref="meetingProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleProjectLinkAssigned"
@open="handleDialogOpen"
@close="handleDialogClose"
/>
<!-- Members Section -->
<div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
@@ -254,6 +308,7 @@
</ul>
<MembersList
ref="membersList"
:password="currentMeeting.password || ''"
:show-organizer-tools="true"
class="mt-4"
@@ -292,10 +347,13 @@ 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 MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
didInfo,
} from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -309,6 +367,8 @@ import {
NOTIFY_MEETING_DELETED,
NOTIFY_MEETING_LINK_COPIED,
} from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
interface ServerMeeting {
groupId: number; // from the server
name: string; // to & from the server
@@ -331,6 +391,8 @@ interface MeetingSetupInputs {
QuickNav,
TopMessage,
MembersList,
MeetingProjectDialog,
ProjectIcon,
},
mixins: [PlatformServiceMixin],
})
@@ -354,6 +416,9 @@ export default class OnboardMeetingView extends Vue {
isRegistered = false;
showDeleteConfirm = false;
fullName = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
selectedProjectData: PlanData | null = null;
get minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
@@ -370,7 +435,17 @@ export default class OnboardMeetingView extends Vue {
this.fullName = settings?.firstName || "";
this.isRegistered = !!settings?.isRegistered;
// Load contacts and DIDs for dialog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
await this.fetchCurrentMeeting();
// Ensure selected project is loaded if projectLink exists
await this.ensureSelectedProjectLoaded();
this.isLoading = false;
}
@@ -442,6 +517,54 @@ export default class OnboardMeetingView extends Vue {
}
}
/**
* Ensure the selected project is loaded if projectLink exists
*/
async ensureSelectedProjectLoaded(): Promise<void> {
const projectLink =
this.currentMeeting?.projectLink ||
this.newOrUpdatedMeetingInputs?.projectLink;
if (!projectLink) {
this.selectedProjectData = null;
return;
}
await this.fetchProjectByHandleId(projectLink);
}
/**
* Fetch a single project by handleId
* @param handleId - The project handleId to fetch
*/
async fetchProjectByHandleId(handleId: string): Promise<void> {
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
const project = resp.data.data[0];
this.selectedProjectData = {
name: project.name,
description: project.description,
image: project.image,
handleId: project.handleId,
issuerDid: project.issuerDid,
rowId: project.rowId,
};
} else {
this.selectedProjectData = null;
}
} catch (error) {
this.$logAndConsole(
"Error fetching project by handleId: " + errorStringForLog(error),
true,
);
this.selectedProjectData = null;
}
}
async createMeeting() {
this.isLoading = true;
@@ -473,6 +596,7 @@ export default class OnboardMeetingView extends Vue {
);
return;
}
const password: string = this.newOrUpdatedMeetingInputs.password;
// create content with user's name & DID encrypted with password
const content = {
@@ -482,7 +606,7 @@ export default class OnboardMeetingView extends Vue {
};
const encryptedContent = await encryptMessage(
JSON.stringify(content),
this.newOrUpdatedMeetingInputs.password,
password,
);
const headers = await getHeaders(this.activeDid);
@@ -505,6 +629,11 @@ export default class OnboardMeetingView extends Vue {
this.newOrUpdatedMeetingInputs = null;
this.notify.success(NOTIFY_MEETING_CREATED.message, TIMEOUTS.STANDARD);
// redirect to the same page with the password parameter set
this.$router.push({
name: "onboard-meeting-setup",
query: { password: password },
});
} else {
throw { response: response };
}
@@ -570,7 +699,7 @@ export default class OnboardMeetingView extends Vue {
}
}
startEditing() {
async startEditing() {
// Populate form with existing meeting data
if (this.currentMeeting) {
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
@@ -581,6 +710,10 @@ export default class OnboardMeetingView extends Vue {
password: this.currentMeeting.password || "",
projectLink: this.currentMeeting.projectLink || "",
};
// Ensure selected project is loaded if projectLink exists
if (this.currentMeeting.projectLink) {
await this.ensureSelectedProjectLoaded();
}
} else {
this.$logError(
"There is no current meeting to edit. We should never get here.",
@@ -588,9 +721,15 @@ export default class OnboardMeetingView extends Vue {
}
}
cancelEditing() {
async cancelEditing() {
// Reset form data
this.newOrUpdatedMeetingInputs = null;
// Restore selected project from currentMeeting if it exists
if (this.currentMeeting?.projectLink) {
await this.ensureSelectedProjectLoaded();
} else {
this.selectedProjectData = null;
}
}
async updateMeeting() {
@@ -704,5 +843,78 @@ export default class OnboardMeetingView extends Vue {
this.notify.error("Failed to copy meeting link to clipboard.");
}
}
/**
* 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,
);
}
/**
* Open the project link selection dialog
*/
openProjectLinkDialog(): void {
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
}
/**
* Handle project assignment from dialog
*/
handleProjectLinkAssigned(project: PlanData): void {
// Store the selected project directly
this.selectedProjectData = project;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
}
}
/**
* Unset the project link and revert to initial state
*/
unsetProjectLink(): void {
this.selectedProjectData = null;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = "";
}
}
/**
* Handle dialog open event - stop auto-refresh in MembersList
*/
handleDialogOpen(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.stopAutoRefresh();
}
}
/**
* Handle dialog close event - start auto-refresh in MembersList
*/
handleDialogClose(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.startAutoRefresh();
}
}
}
</script>

View File

@@ -57,6 +57,9 @@
<button :class="sqlLinkClasses" @click="setAccountsQuery">
Accounts
</button>
<button :class="sqlLinkClasses" @click="setActiveIdentityQuery">
Active DID
</button>
<button :class="sqlLinkClasses" @click="setContactsQuery">
Contacts
</button>
@@ -525,6 +528,11 @@ export default class Help extends Vue {
this.executeSql();
}
setActiveIdentityQuery() {
this.sqlQuery = "SELECT * FROM active_identity;";
this.executeSql();
}
setContactsQuery() {
this.sqlQuery = "SELECT * FROM contacts;";
this.executeSql();

View File

@@ -54,6 +54,108 @@
</p>
</div>
<!-- Nearest Neighbors Section -->
<div
v-if="
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
profile.issuerDid !== neighbors?.[0]?.did // and they're not directly connected (since there's no in-between)
"
class="mt-6"
>
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
<div v-if="loadingNeighbors">
<div class="flex justify-center items-center py-8">
<font-awesome
icon="spinner"
class="fa-spin-pulse text-2xl text-slate-400"
/>
</div>
</div>
<div
v-else-if="neighborsError"
class="bg-red-50 border border-red-300 rounded-md p-4"
>
<div class="flex items-start gap-2">
<font-awesome
icon="exclamation-triangle"
class="text-red-500 mt-0.5"
/>
<p class="text-red-700">{{ neighborsError }}</p>
</div>
</div>
<div v-else>
<div class="space-y-2">
<div
v-for="neighbor in neighbors"
:key="neighbor.did"
class="bg-slate-50 border border-slate-300 rounded-md"
>
<div class="flex items-center justify-between gap-3 p-3">
<div class="flex items-center gap-2 flex-1 min-w-0">
<button
title="Copy profile link and expand"
class="text-blue-600 flex-shrink-0"
@click="onNeighborExpandClick(neighbor.did)"
>
<font-awesome
:icon="
expandedNeighborDid === neighbor.did
? 'chevron-down'
: 'chevron-right'
"
class="text-sm"
/>
{{ getNeighborDisplayName(neighbor.did) }}
</button>
<span :class="getRelationBadgeClass(neighbor.relation)">
{{ getRelationLabel(neighbor.relation) }}
</span>
</div>
</div>
<div
v-if="expandedNeighborDid === neighbor.did"
class="border-t border-slate-300 p-3 bg-white"
>
<router-link
:to="{ name: 'did', params: { did: neighbor.did } }"
class="text-blue-600 hover:text-blue-800 font-medium underline"
>
Go to contact info
</router-link>
and send them the link in your clipboard and ask for an
introduction to this person.
<div
v-if="
getNeighborDisplayName(neighbor.did) === '' ||
neighborIsNotInContacts(neighbor.did)
"
class="flex flex-col gap-1 mt-2"
>
<p class="text-xs text-slate-600">
This person is connected to you, but they are not in this
device's contacts. Copy this DID link and check on another
device or check with different people.
</p>
<span class="flex items-center gap-1 min-w-0">
<span class="text-xs truncate text-slate-600 min-w-0">
{{ neighbor.did }}
</span>
<button
title="Copy DID Link"
class="text-blue-600 hover:text-blue-800 underline cursor-pointer flex-shrink-0"
@click.prevent="onCopyDidClick(neighbor.did)"
>
<font-awesome icon="copy" class="text-sm" />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map for first coordinates -->
<div v-if="hasFirstLocation" class="mt-4">
<h2 class="text-lg font-semibold">Location</h2>
@@ -159,7 +261,11 @@ export default class UserProfileView extends Vue {
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
expandedNeighborDid: string | null = null;
isLoading = true;
loadingNeighbors = false;
neighbors: Array<{ did: string; relation: string }> = [];
neighborsError = "";
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null;
@@ -183,8 +289,8 @@ export default class UserProfileView extends Vue {
*/
async mounted() {
await this.initializeSettings();
await this.loadContacts();
await this.loadProfile();
await this.loadNeighbors();
}
/**
@@ -199,12 +305,7 @@ export default class UserProfileView extends Vue {
this.activeDid = activeIdentity.activeDid || "";
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
}
/**
* Loads all contacts from database
*/
private async loadContacts() {
this.allContacts = await this.$getAllContacts();
this.allMyDids = await retrieveAccountDids();
}
@@ -249,23 +350,100 @@ export default class UserProfileView extends Vue {
}
/**
* Copies profile link to clipboard
* Loads nearest neighbors from partner API
*
* Creates a deep link to the profile and copies it to the clipboard
* Shows success notification when completed
* Fetches network connections for the profile and displays them
* with appropriate relation labels
*/
async loadNeighbors() {
const profileId: string = this.$route.params.id as string;
if (!profileId) {
return;
}
this.loadingNeighbors = true;
this.neighborsError = "";
try {
const response = await fetch(
`${this.partnerApiServer}/api/partner/userProfileNearestNeighbors/${encodeURIComponent(profileId)}`,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status === 200) {
const result = await response.json();
this.neighbors = result.data;
this.neighborsError = "";
} else {
logger.warn("Failed to load neighbors:", response.status);
this.neighbors = [];
this.neighborsError = "Failed to load network connections.";
}
} catch (error) {
logger.error("Error loading neighbors:", error);
this.neighbors = [];
this.neighborsError =
"An error occurred while loading network connections.";
} finally {
this.loadingNeighbors = false;
}
}
/**
* Copies a deep link to the profile to the clipboard
*/
async onCopyLinkClick() {
// Use production URL for sharing to avoid localhost issues in development
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("profile link", TIMEOUTS.STANDARD);
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link.");
}
}
/**
* Copies a deep link to the provided DID to the clipboard
*/
async onCopyDidClick(did: string) {
const deepLink = `${APP_SERVER}/deep-link/did/${encodeURIComponent(did)}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("DID link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying DID link: ${error}`, true);
this.notify.error("Failed to copy DID link.");
}
}
/**
* Handles clicking the expand button next to a neighbor's name
* Copies the profile link to clipboard and toggles the expanded section
*/
async onNeighborExpandClick(did: string) {
if (this.expandedNeighborDid === did) {
this.expandedNeighborDid = null;
// don't copy the link
return;
}
// Copy the profile link
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link.");
}
// Toggle the expanded section
this.expandedNeighborDid = did;
}
/**
* Computed properties for template logic streamlining
*/
@@ -330,5 +508,64 @@ export default class UserProfileView extends Vue {
get tileLayerUrl() {
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
}
/**
* Gets display name for a neighbor's DID
* Uses didInfo utility to show contact name if available, otherwise DID
* @param did - The DID to get display name for
* @returns Formatted display name
*/
getNeighborDisplayName(did: string): string {
return this.didInfo(did, this.activeDid, this.allMyDids, this.allContacts);
}
neighborIsNotInContacts(did: string) {
return !this.allContacts.some((contact) => contact.did === did);
}
noNeighborsAreInContacts() {
return this.neighbors.every(
(neighbor) =>
!this.allContacts.some((contact) => contact.did === neighbor.did),
);
}
/**
* Gets human-readable label for relation type
* @param relation - The relation type from API
* @returns Display label for the relation
*/
getRelationLabel(relation: string): string {
switch (relation) {
case "REGISTERED_BY_YOU":
return "Registered by You";
case "REGISTERED_YOU":
return "Registered You";
case "TARGET":
return "Yourself";
default:
return relation;
}
}
/**
* Gets CSS classes for relation badge styling
* @param relation - The relation type from API
* @returns CSS class string for badge
*/
getRelationBadgeClass(relation: string): string {
const baseClasses =
"text-xs font-semibold px-2 py-1 rounded whitespace-nowrap";
switch (relation) {
case "REGISTERED_BY_YOU":
return `${baseClasses} bg-blue-100 text-blue-700`;
case "REGISTERED_YOU":
return `${baseClasses} bg-green-100 text-green-700`;
case "TARGET":
return `${baseClasses} bg-purple-100 text-purple-700`;
default:
return `${baseClasses} bg-slate-100 text-slate-700`;
}
}
}
</script>