Compare commits

...

60 Commits

Author SHA1 Message Date
f64846ae17 fix issue showing ID without name when affirming delivery of an offer 2025-12-30 18:28:54 -07:00
24b636cd2f bump version and add "-beta" for work before next release 2025-12-30 07:10:42 -07:00
faef83a664 bump to version 1.1.5 2025-12-30 07:08:26 -07:00
c992afe4d4 Merge pull request 'feat: remove the 'lock' from the giving & receiving sides' (#229) from no-locks into master
Reviewed-on: #229
2025-12-23 03:23:07 +00:00
941d93f6db fix: disallow affirming delivery of orders where giver is hidden 2025-12-20 20:20:10 -07:00
f460d6c3e2 fix: adjust playwright tests to new home-page button, and use 3 threads for fewer random failures 2025-12-20 19:59:30 -07:00
e7ca2bb791 fix: verbiage on pop-up for giving/receiving person/project 2025-12-20 19:09:47 -07:00
b864f1632d feat: remove the 'lock' from the giving & receiving sides 2025-12-20 18:58:18 -07:00
ffeac44b39 chore: bump version and add "-beta" 2025-12-18 20:29:03 -07:00
08d55519e6 chore: bump version to 1.1.4 2025-12-18 20:27:30 -07:00
bf8694fc75 fix: change the Apple group ID to something that works, ie group.app.timesafari.share 2025-12-18 20:26:02 -07:00
386b7604eb Merge pull request 'allow changing of both giver and receiver to projects or people' (#228) from giver-receiver-selectable into master
Reviewed-on: #228
2025-12-19 02:10:06 +00:00
9260892838 Revert "feat: make the new "+" appear centered on the home button"
This reverts commit fe1df9a9fb.
2025-12-18 08:56:54 -07:00
fe1df9a9fb feat: make the new "+" appear centered on the home button 2025-12-18 08:51:21 -07:00
7ef5889185 feat: make it so there's no jump when scrolling down on home feed, and the new "+" slowly appears 2025-12-18 08:46:36 -07:00
3a4cdf78d8 fix: show starred projects with most-recently-added first 2025-12-18 08:34:48 -07:00
0697b14411 refactor: change word on first button to "Thank", and center on button when it drops down 2025-12-18 08:30:07 -07:00
7aea818f01 feat: shrink & reword the button on the front, and put it at the bottom when they scroll down 2025-12-17 21:43:49 -07:00
d4a7c0dda0 feat: make the project/person selector on pop-up right-justified 2025-12-17 21:26:21 -07:00
34a7119086 feat: disallow selection of a person or project if it's already selected on the other side (giver/receiver) 2025-12-17 21:20:27 -07:00
70a0ef7ef6 feat: allow changing of both giver and receiver to projects or people 2025-12-17 21:08:09 -07:00
306e221479 feat: add starred projects at the top of the list to choose 2025-12-17 18:18:37 -07:00
4b118b0b91 Merge pull request 'web-share-target-native-implementation' (#227) from web-share-target-native-implementation into master
Reviewed-on: #227
2025-12-16 06:44:55 +00:00
3d2201fc17 fix: add intent-handling and automated stream-closing to Java 2025-12-10 18:53:08 -07:00
Jose Olarte III
bb92e3ac4f refactor(ios): preserve original image formats in share extension
Remove JPEG conversion and preserve original image formats and filenames
to test support for all image file types. Refactor image processing logic
for clarity and maintainability.

Changes:
- Remove forced JPEG conversion (was using 0.9 compression quality)
- Preserve original filename extensions instead of forcing .jpg
- Refactor from 3 branches to 2 (removed unlikely UIImage branch)
- Replace if statement with guard for non-image attachment filtering
- Simplify error check from optional binding to nil check
- Extract filename extension helper method to reduce duplication
- Update default filename from "shared-image.jpg" to "shared-image"

The only conversion that may occur is to PNG (lossless) when raw data
cannot be read from a file URL, preserving image quality while ensuring
compatibility.
2025-12-10 19:34:46 +08:00
Jose Olarte III
a672c977a8 Fix Android image share workflow and add local plugin persistence
- Add SafeArea and SharedImage plugins to capacitor.plugins.json
- Create restore-local-plugins.js to persist plugins after cap sync
- Integrate plugin restoration into build scripts
- Improve Android share intent timing with retry logic
- Clean up debug logging while keeping essential error handling
- Add documentation for local plugin management

Fixes issue where SharedImage plugin wasn't recognized, causing
"UNIMPLEMENTED" errors. Image sharing now works correctly on Android.
2025-12-09 21:36:01 +08:00
38b137a86b doc: enhance and dedup some build instructions 2025-12-08 19:59:48 -07:00
dbd18bba6c allow to see the discover page without the onboarding message 2025-12-08 19:57:58 -07:00
Jose Olarte III
0c66142093 Fix iOS share extension large image handling
- Fix large images not loading from share sheet (exceeded UserDefaults 4MB limit)
- Store all shared images as files in App Group container instead of base64 in UserDefaults
- Fix image refresh when sharing new image while already on SharedPhotoView
- Add route query watcher to detect _refresh parameter changes
- Remove debug logging and redundant UIImage check

The previous implementation tried to store base64-encoded images in UserDefaults,
which failed for large images (HEIF images were often 6MB+ when base64 encoded). Now all images
are stored as files in the App Group container, with only the file path stored
in UserDefaults. This supports images of any size and provides consistent
behavior regardless of format.

Also fixes an issue where sharing a new image while already viewing
SharedPhotoView wouldn't refresh the displayed image.
2025-12-04 20:39:26 +08:00
Jose Olarte III
84983ee10b refactor(shared-image): replace temp file approach with native Capacitor plugins
Replace the buggy temp file polling mechanism with direct native-to-JS
communication via custom Capacitor plugins for iOS and Android. This
eliminates race conditions, file management complexity, and polling overhead.

BREAKING CHANGE: Removes backward compatibility with temp file approach.
Android minSdkVersion upgraded from 22 to 23.

Changes:
- iOS: Created SharedImagePlugin (CAPBridgedPlugin) and SharedImageUtility
  - Uses App Group UserDefaults for data sharing between extension and app
  - Implements getSharedImage() and hasSharedImage() methods
  - Removes temp file writing/reading logic from AppDelegate
- Android: Created SharedImagePlugin with @CapacitorPlugin annotation
  - Uses SharedPreferences for data storage instead of temp files
  - Implements same interface as iOS plugin
  - Removes temp file handling from MainActivity
- TypeScript: Added plugin definitions and registration
  - Created SharedImagePlugin interface and web fallback
  - Updated main.capacitor.ts to use plugin instead of Filesystem API
  - Removed pollForFileExistence() and related file I/O code
- Android: Upgraded minSdkVersion from 22 to 23
  - Required for SharedPreferences improvements and better API support

Benefits:
- Eliminates race conditions from file polling
- Removes file cleanup complexity
- Direct native-to-JS communication (no file I/O)
- Better performance (no polling overhead)
- More reliable data transfer between share extension and app
- Cleaner architecture with proper plugin abstraction
2025-12-04 18:03:47 +08:00
Jose Olarte III
eeac7fdb66 fix(deps): align FontAwesome brands package version with other packages
Downgrade @fortawesome/free-brands-svg-icons from ^7.1.0 to ^6.5.1 to match
the version of @fortawesome/fontawesome-svg-core and other FontAwesome
packages. This resolves a TypeScript type error where IconDefinition from
the brands package was incompatible with the library.add() function due to
version mismatch.
2025-12-01 17:10:45 +08:00
Jose Olarte III
1a8383bc63 fix: update Android share intent handling for API 33+ compatibility
Fix deprecation warnings and ensure future compatibility by updating
getParcelableExtra() calls to use the new API introduced in Android 13
(API 33), while maintaining backward compatibility with older versions.

Changes:
- Update getParcelableExtra() to use typed version (Uri.class) on API 33+
- Update getParcelableArrayListExtra() to use typed version on API 33+
- Add version checks with Build.VERSION.SDK_INT >= TIRAMISU
- Maintain backward compatibility for API 22-32 using deprecated API
  with @SuppressWarnings("deprecation")
- No functional changes - share feature works identically across versions

The new API (getParcelableExtra(key, Class)) was introduced in API 33
to provide type safety. The old API is deprecated but still functional
on older versions, so we use runtime checks to support both.

This ensures the app builds cleanly with targetSdkVersion 36 and remains
compatible with minSdkVersion 22.
2025-11-28 16:43:36 +08:00
Jose Olarte III
4c771d8be3 fix: update iOS share extension deployment target and fix compatibility issues
Fix share extension not appearing in share sheet on iOS 18.6 and earlier
versions by updating deployment target and resolving API availability issues.

Changes:
- Update share extension deployment target from 26.1 to 14.0
  - iOS 18.6 cannot use extensions requiring iOS 26.1
  - UTType API requires iOS 14.0+ (used in ShareViewController)
- Update share extension display name from "TimeSafariShareExtension" to
  "TimeSafari" for cleaner appearance in share sheet
- Fix compiler warning: replace unused error binding with boolean check
  (if let error = error → if error != nil)

The share extension now works on iOS 14.0+ (matching UTType requirements)
and displays properly in the share sheet on older iOS versions.
2025-11-28 15:47:10 +08:00
Jose Olarte III
0627cd32b7 refactor: remove unused ShareImage plugin code, simplify to temp file approach
Remove unused Capacitor plugin implementation and simplify shared image
handling to use only the temp file approach, which is already working
reliably for both iOS and Android.

Changes:
- Delete ios/App/App/ShareImagePlugin.swift (unused, never registered)
- Delete ios/App/App/ShareImageBridge.swift (only used by plugin)
- Remove ~80 lines of plugin fallback code from src/main.capacitor.ts
- Simplify error handling to single code path
- Add documentation comment explaining temp file approach and future
  plugin implementation possibility

Benefits:
- ~154 lines of code removed
- Single, consistent code path for both platforms
- Easier to maintain and debug
- No functional changes - temp file approach already working

The temp file approach uses Capacitor's Filesystem plugin to read shared
image data written by native code (AppDelegate on iOS, MainActivity on
Android). This is simpler and more reliable than the plugin approach,
which required registration and bridge setup.

Future improvement: Consider implementing Capacitor plugins for direct
native-to-JS communication if lower latency becomes a priority, though
the current approach performs well in production.
2025-11-27 21:23:34 +08:00
Jose Olarte III
e1eb91f26d feat: Add Android share target support for image sharing
Implement native Android share functionality to allow users to share
images from other apps directly to TimeSafari. This mirrors the iOS
share extension functionality and provides a seamless cross-platform
experience.

Changes:
- Add ACTION_SEND and ACTION_SEND_MULTIPLE intent filters to
  AndroidManifest.xml to register the app as a share target for images
- Implement share intent handling in MainActivity.java:
  - Process incoming share intents in onCreate() and onNewIntent()
  - Read shared image data from content URI using ContentResolver
  - Convert image to Base64 encoding
  - Write image data to temporary JSON file in app's internal storage
  - Use getFilesDir() which maps to Capacitor's Directory.Data
- Update src/main.capacitor.ts to support Android platform:
  - Extend checkAndStoreNativeSharedImage() to check for Android platform
  - Use Directory.Data for Android file operations (vs Directory.Documents for iOS)
  - Add Android to initial load and app state change listeners
  - Ensure shared image detection works on app launch and activation

The implementation follows the same pattern as iOS:
- Native layer (MainActivity) writes shared image to temp file
- JavaScript layer polls for file existence with exponential backoff
- Image is stored in temp database and user is navigated to SharedPhotoView

This enables users to share images from Gallery, Photos, and other apps
directly to TimeSafari on Android devices.
2025-11-26 20:01:14 +08:00
Jose Olarte III
09a230f43e refactor(ios): improve file polling and extract shared image storage logic
Replace hardcoded timeout with reliable file existence polling and extract
duplicate image storage code into reusable function.

File Polling Improvements:
- Replace hardcoded 300ms timeout with pollForFileExistence() function
- Use Filesystem.stat() to check file existence instead of guessing timing
- Implement exponential backoff (100ms, 200ms, 400ms, 800ms, 1600ms)
- Maximum 5 retries with configurable parameters
- More reliable: actually checks if file exists rather than fixed wait time

Code Deduplication:
- Extract storeSharedImageInTempDB() function to eliminate code duplication
- Consolidates clearing old data, base64-to-dataURL conversion, and DB storage
- Used in both temp file and plugin fallback code paths
- Improves maintainability and reduces risk of inconsistencies

This makes the code more robust and maintainable while ensuring the file
is actually available before attempting to read it.
2025-11-25 18:56:45 +08:00
Jose Olarte III
eff4126043 feat(ios): improve share extension UX and fix image reload issues
Improve iOS share extension implementation to skip interstitial UI and
fix issues with subsequent image shares not updating the view.

iOS Share Extension Improvements:
- Replace SLComposeServiceViewController with custom UIViewController
  to eliminate interstitial "Post" button UI
- Use minimal URL (timesafari://) instead of deep link for app launch
- Implement app lifecycle detection via Capacitor appStateChange listener
  instead of relying solely on deep links

Deep Link and Navigation Fixes:
- Remove "shared-photo" from deep link schemas (no longer needed)
- Add empty path URL handling for share extension launches
- Implement processing lock to prevent duplicate image processing
- Add retry mechanism (300ms delay) to handle race conditions with
  AppDelegate writing temp files
- Use router.replace() when already on /shared-photo route to force refresh
- Clear old images from temp DB before storing new ones
- Delete temp file immediately after reading to prevent stale data

SharedPhotoView Component:
- Add route watcher (@Watch) to reload image when fileName query
  parameter changes
- Extract image loading logic into reusable loadSharedImage() method
- Improve error handling to clear image state on failures

This fixes the issue where sharing a second image while already on
SharedPhotoView would display the previous image instead of the new one.
2025-11-25 18:22:43 +08:00
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
83b470e28a fix: link from DID page to Help 2025-11-16 15:35:19 -07: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
5050156beb fix: a type, plus add the type-check to the mobile build scripts 2025-11-08 08:31:42 -07: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
71 changed files with 5450 additions and 573 deletions

View File

@@ -175,27 +175,6 @@ cp .env.example .env.development
### Troubleshooting Quick Fixes
#### Common Issues
```bash
# Clean and rebuild
npm run clean:all
npm install
npm run build:web:dev
# Reset mobile projects
npm run clean:ios
npm run clean:android
npm run build:ios # Regenerates iOS project
npm run build:android # Regenerates Android project
# Fix Android asset issues
npm run assets:validate:android # Validates and regenerates missing Android assets
# Check environment
npm run test:web # Verifies web setup
```
#### Platform-Specific Issues
- **iOS**: Ensure Xcode and Command Line Tools are installed
@@ -385,14 +364,13 @@ rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safa
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
- For prod, get on the server and run the correct build:
- For prod, you can do the same with `build:web:prod` instead.
... and log onto the server:
... or log onto the server (though the build step can stay on "rendering chunks" for a long while):
- `pkgx +npm sh`
- `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout
1.0.2 && npm install && npm run build:web:prod && cd -`
- `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.2 && npm install && npm run build:web:prod && cd -`
(The plain `npm run build:web:prod` uses the .env.production file.)
@@ -1142,37 +1120,38 @@ If you need to build manually or want to understand the individual steps:
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
- In the App Developer setup (eg. https://developer.apple.com/account), under Identifiers and/or "Certificates, Identifiers & Profiles"
#### Each Release
##### 0. First time (or if dependencies change)
- `pkgx +rubygems.org sh`
- `pkgx +rubygems.org zsh`
- ... 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 for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
cd ios/App && xcrun agvtool new-version 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/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 50 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.5;/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 +1176,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 +1283,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 +1295,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 46/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle
```
```bash
perl -p -i -e 's/versionCode .*/versionCode 50/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.5"/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 +1359,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

@@ -6,6 +6,37 @@ 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.5] - 2025.12.28
### Fixed
- Incorrect prompts in give-dialog on a project or offer
## [1.1.4] - 2025.12.18
### Fixed
- Contact notes & contact methods preserved in export
### Added
- This is a target for sharing
- Switch to a project or person in give-dialog pop-up
- Starred projects onto project-choice in give-dialog pop-up
### Changed
- Front page: 1 green "Thank" button
## [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

View File

@@ -279,13 +279,11 @@ The application uses a platform-agnostic database layer with Vue mixins for serv
* `src/services/PlatformServiceFactory.ts` - Platform-specific service factory
* `src/services/AbsurdSqlDatabaseService.ts` - SQLite implementation
* `src/utils/PlatformServiceMixin.ts` - Vue mixin for database access with caching
* `src/db/` - Legacy Dexie database (migration in progress)
**Development Guidelines**:
- Always use `PlatformServiceMixin` for database operations in components
- Test with PlatformServiceMixin for new features
- Use migration tools for data transfer between systems
- Leverage mixin's ultra-concise methods: `$db()`, `$exec()`, `$one()`, `$contacts()`, `$settings()`
**Architecture Decision**: The project uses Vue mixins over Composition API composables for platform service access. See [Architecture Decisions](doc/architecture-decisions.md) for detailed rationale.
@@ -305,21 +303,9 @@ timesafari/
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
```
## Known Issues
### Critical Vue Reactivity Bug
A critical Vue reactivity bug was discovered during ActiveDid migration testing where component properties fail to trigger template updates correctly.
**Impact**: The `newDirectOffersActivityNumber` element in HomeView.vue requires a watcher workaround to render correctly.
**Status**: Workaround implemented, investigation ongoing.
**Documentation**: See [Vue Reactivity Bug Report](doc/vue-reactivity-bug-report.md) for details.
## 🤝 Contributing
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
2. **Use the PR template** - Complete the checklist for build-related changes
3. **Test your changes** - Ensure builds work on affected platforms
4. **Document updates** - Keep BUILDING.md current and accurate

View File

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

View File

@@ -27,6 +27,20 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
<!-- Share Target Intent Filter - Single Image -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Share Target Intent Filter - Multiple Images (optional, we'll handle first image) -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<provider

View File

@@ -34,5 +34,13 @@
{
"pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
},
{
"pkg": "SafeArea",
"classpath": "app.timesafari.safearea.SafeAreaPlugin"
},
{
"pkg": "SharedImage",
"classpath": "app.timesafari.sharedimage.SharedImagePlugin"
}
]

View File

@@ -1,6 +1,10 @@
package app.timesafari;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Base64;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.view.WindowInsetsController;
@@ -11,9 +15,21 @@ import android.webkit.WebSettings;
import android.webkit.WebViewClient;
import com.getcapacitor.BridgeActivity;
import app.timesafari.safearea.SafeAreaPlugin;
import app.timesafari.sharedimage.SharedImagePlugin;
//import com.getcapacitor.community.sqlite.SQLite;
import android.content.SharedPreferences;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
public class MainActivity extends BridgeActivity {
private static final String TAG = "MainActivity";
private static final String SHARED_PREFS_NAME = "shared_image";
private static final String KEY_BASE64 = "shared_image_base64";
private static final String KEY_FILE_NAME = "shared_image_file_name";
private static final String KEY_READY = "shared_image_ready";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -48,9 +64,156 @@ public class MainActivity extends BridgeActivity {
// Register SafeArea plugin
registerPlugin(SafeAreaPlugin.class);
// Register SharedImage plugin
registerPlugin(SharedImagePlugin.class);
// Initialize SQLite
//registerPlugin(SQLite.class);
// Handle share intent if app was launched from share sheet
handleShareIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleShareIntent(intent);
}
/**
* Handle share intents (ACTION_SEND or ACTION_SEND_MULTIPLE)
* Processes shared images and stores them in SharedPreferences for plugin to read
*/
private void handleShareIntent(Intent intent) {
if (intent == null) {
return;
}
String action = intent.getAction();
String type = intent.getType();
boolean handled = false;
// Handle single image share
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
Uri imageUri;
// Use new API for API 33+ (Android 13+), fall back to deprecated API for older versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class);
} else {
// Deprecated but still works on older versions
@SuppressWarnings("deprecation")
Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
imageUri = uri;
}
if (imageUri != null) {
String fileName = intent.getStringExtra(Intent.EXTRA_TEXT);
processSharedImage(imageUri, fileName);
handled = true;
}
}
// Handle multiple images share (we'll just process the first one)
else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
java.util.ArrayList<Uri> imageUris;
// Use new API for API 33+ (Android 13+), fall back to deprecated API for older versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class);
} else {
// Deprecated but still works on older versions
@SuppressWarnings("deprecation")
java.util.ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
imageUris = uris;
}
if (imageUris != null && !imageUris.isEmpty()) {
processSharedImage(imageUris.get(0), null);
handled = true;
}
}
// Clear the intent after handling to release URI permissions and prevent
// network issues in WebView. This is critical for preventing the WebView
// from losing network connectivity after processing shared content.
if (handled) {
intent.setAction(null);
intent.setData(null);
intent.removeExtra(Intent.EXTRA_STREAM);
intent.setType(null);
setIntent(new Intent());
Log.d(TAG, "Cleared share intent after processing");
}
}
/**
* Process a shared image: read it, convert to base64, and write to temp file
* Uses try-with-resources to ensure proper stream cleanup and prevent network issues
*/
private void processSharedImage(Uri imageUri, String fileName) {
// Extract filename from URI or use default (do this before opening streams)
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";
}
}
// Use try-with-resources to ensure streams are properly closed
// This is critical to prevent resource leaks that can affect WebView networking
try (InputStream inputStream = getContentResolver().openInputStream(imageUri);
ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
if (inputStream == null) {
Log.e(TAG, "Failed to open input stream for shared image");
return;
}
// Read image bytes
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);
// Store in SharedPreferences for plugin to read
storeSharedImageInPreferences(base64String, actualFileName);
Log.d(TAG, "Successfully processed shared image: " + actualFileName);
} catch (IOException e) {
Log.e(TAG, "Error processing shared image", e);
} catch (Exception e) {
Log.e(TAG, "Unexpected error processing shared image", e);
}
}
/**
* Store shared image data in SharedPreferences for plugin to read
* Plugin will read and clear the data when called
*/
private void storeSharedImageInPreferences(String base64, String fileName) {
try {
SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.putString(KEY_BASE64, base64);
editor.putString(KEY_FILE_NAME, fileName);
editor.putBoolean(KEY_READY, true);
editor.apply();
Log.d(TAG, "Stored shared image data in SharedPreferences");
} catch (Exception e) {
Log.e(TAG, "Error storing shared image in SharedPreferences", e);
}
}
}

View File

@@ -0,0 +1,84 @@
package app.timesafari.sharedimage;
import android.content.Context;
import android.content.SharedPreferences;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "SharedImage")
public class SharedImagePlugin extends Plugin {
private static final String SHARED_PREFS_NAME = "shared_image";
private static final String KEY_BASE64 = "shared_image_base64";
private static final String KEY_FILE_NAME = "shared_image_file_name";
private static final String KEY_READY = "shared_image_ready";
/**
* Get shared image data from SharedPreferences
* Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading
*/
@PluginMethod
public void getSharedImage(PluginCall call) {
try {
SharedPreferences prefs = getSharedPreferences();
String base64 = prefs.getString(KEY_BASE64, null);
String fileName = prefs.getString(KEY_FILE_NAME, null);
if (base64 == null || fileName == null) {
// No shared image exists - return null values (not an error)
JSObject result = new JSObject();
result.put("base64", (String) null);
result.put("fileName", (String) null);
call.resolve(result);
return;
}
// Clear the shared data after reading
SharedPreferences.Editor editor = prefs.edit();
editor.remove(KEY_BASE64);
editor.remove(KEY_FILE_NAME);
editor.remove(KEY_READY);
editor.apply();
// Return the shared image data
JSObject result = new JSObject();
result.put("base64", base64);
result.put("fileName", fileName);
call.resolve(result);
} catch (Exception e) {
android.util.Log.e("SharedImagePlugin", "Error in getSharedImage()", e);
call.reject("Error getting shared image: " + e.getMessage());
}
}
/**
* Check if shared image exists without reading it
* Useful for quick checks before calling getSharedImage()
*/
@PluginMethod
public void hasSharedImage(PluginCall call) {
SharedPreferences prefs = getSharedPreferences();
boolean hasImage = prefs.contains(KEY_BASE64) && prefs.contains(KEY_FILE_NAME);
JSObject result = new JSObject();
result.put("hasImage", hasImage);
call.resolve(result);
}
/**
* Get SharedPreferences instance for shared image data
*/
private SharedPreferences getSharedPreferences() {
Context context = getContext();
if (context == null) {
throw new IllegalStateException("Plugin context is null");
}
return context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE);
}
}

View File

@@ -1,5 +1,5 @@
ext {
minSdkVersion = 22
minSdkVersion = 23
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.8.0'

View File

@@ -0,0 +1,259 @@
# Android API 23 Upgrade Impact Analysis
**Date:** 2025-12-03
**Current minSdkVersion:** 22 (Android 5.1 Lollipop)
**Proposed minSdkVersion:** 23 (Android 6.0 Marshmallow)
**Impact Assessment:** Low to Moderate
## Executive Summary
Upgrading from API 22 to API 23 will have **minimal code impact** but may affect device compatibility. The main change is that API 23 introduced runtime permissions, but since the app uses Capacitor plugins which handle permissions, the impact is minimal.
## Code Impact Analysis
### ✅ No Breaking Changes in Existing Code
#### 1. API Level Checks in Code
All existing API level checks are for **much higher APIs** than 23, so they won't be affected:
**MainActivity.java:**
- `Build.VERSION_CODES.R` (API 30+) - Edge-to-edge display
- `Build.VERSION_CODES.TIRAMISU` (API 33+) - Intent extras handling
- Legacy path (API 21-29) - Will still work, but API 22 devices won't be supported
**SafeAreaPlugin.java:**
- `Build.VERSION_CODES.R` (API 30+) - Safe area insets
**Conclusion:** No code changes needed for API level checks.
#### 2. Permissions Handling
**Current Permissions in AndroidManifest.xml:**
- `INTERNET` - Normal permission (no runtime needed)
- `READ_EXTERNAL_STORAGE` - Dangerous permission (runtime required on API 23+)
- `WRITE_EXTERNAL_STORAGE` - Dangerous permission (runtime required on API 23+)
- `CAMERA` - Dangerous permission (runtime required on API 23+)
**Current Implementation:**
- ✅ App uses **Capacitor plugins** for camera and file access
- ✅ Capacitor plugins **already handle runtime permissions** automatically
- ✅ No manual permission request code found in the codebase
- ✅ QR Scanner uses Capacitor's BarcodeScanner plugin which handles permissions
**Conclusion:** No code changes needed - Capacitor handles runtime permissions automatically.
#### 3. Dependencies Compatibility
**AndroidX Libraries:**
- `androidx.appcompat:appcompat:1.6.1` - ✅ Supports API 23+
- `androidx.core:core:1.12.0` - ✅ Supports API 23+
- `androidx.fragment:fragment:1.6.2` - ✅ Supports API 23+
- `androidx.coordinatorlayout:coordinatorlayout:1.2.0` - ✅ Supports API 23+
- `androidx.core:core-splashscreen:1.0.1` - ✅ Supports API 23+
**Capacitor Plugins:**
- `@capacitor/core:6.2.0` - ✅ Requires API 23+ (official requirement)
- `@capacitor/camera:6.0.0` - ✅ Handles runtime permissions
- `@capacitor/filesystem:6.0.0` - ✅ Handles runtime permissions
- `@capacitor-community/sqlite:6.0.2` - ✅ Supports API 23+
- `@capacitor-mlkit/barcode-scanning:6.0.0` - ✅ Supports API 23+
**Third-Party Libraries:**
- No Firebase or other libraries with API 22-specific requirements found
- All dependencies appear compatible with API 23+
**Conclusion:** All dependencies are compatible with API 23.
#### 4. Build Configuration
**Current Configuration:**
- `compileSdkVersion = 36` (Android 14)
- `targetSdkVersion = 36` (Android 14)
- `minSdkVersion = 22` (Android 5.1) ← **Only this needs to change**
**Required Change:**
```gradle
// android/variables.gradle
ext {
minSdkVersion = 23 // Change from 22 to 23
// ... rest stays the same
}
```
**Conclusion:** Only one line needs to be changed.
## Device Compatibility Impact
### Device Coverage Loss
**API 22 (Android 5.1 Lollipop):**
- Released: March 2015
- Market share: ~0.1% of active devices (as of 2024)
- Devices affected: Very old devices from 2015-2016
**API 23 (Android 6.0 Marshmallow):**
- Released: October 2015
- Market share: ~0.3% of active devices (as of 2024)
- Still very low, but slightly higher than API 22
**Impact:** Losing support for ~0.1% of devices (essentially negligible)
### User Base Impact
**Recommendation:** Check your analytics to see actual usage:
- If you have analytics, check percentage of users on API 22
- If < 0.5%, upgrade is safe
- If > 1%, consider the business impact
## Runtime Permissions (API 23 Feature)
### What Changed in API 23
**Before API 23 (API 22 and below):**
- Permissions granted at install time
- User sees all permissions during installation
- No runtime permission dialogs
**API 23+ (Runtime Permissions):**
- Dangerous permissions must be requested at runtime
- User sees permission dialogs when app needs them
- Better user experience and privacy
### Current App Status
**✅ Already Compatible:**
- App uses Capacitor plugins which **automatically handle runtime permissions**
- Camera plugin requests permissions when needed
- Filesystem plugin requests permissions when needed
- No manual permission code needed
**Conclusion:** App is already designed for runtime permissions via Capacitor.
## Potential Issues to Watch
### 1. APK Size
- Some developers report APK size increases after raising minSdkVersion
- **Action:** Monitor APK size after upgrade
- **Expected Impact:** Minimal (API 22 → 23 is a small jump)
### 2. Testing Requirements
- Need to test on API 23+ devices
- **Action:** Test on Android 6.0+ devices/emulators
- **Current:** App likely already tested on API 23+ devices
### 3. Legacy Code Path
- MainActivity has legacy code for API 21-29
- **Impact:** This code will still work, but API 22 devices won't be supported
- **Action:** No code changes needed, but legacy path becomes API 23-29
### 4. Capacitor Compatibility
- Capacitor 6.2.0 officially requires API 23+
- **Current Situation:** App runs on API 22 (may be working due to leniency)
- **After Upgrade:** Officially compliant with Capacitor requirements
- **Benefit:** Better compatibility guarantees
## Files That Need Changes
### 1. Build Configuration
**File:** `android/variables.gradle`
```gradle
ext {
minSdkVersion = 23 // Change from 22
// ... rest unchanged
}
```
### 2. Documentation
**Files to Update:**
- `doc/shared-image-plugin-implementation-plan.md` - Update version notes
- Any README files mentioning API 22
- Build documentation
### 3. No Code Changes Required
- ✅ No Java/Kotlin code changes needed
- ✅ No AndroidManifest.xml changes needed
- ✅ No permission handling code changes needed
## Testing Checklist
After upgrading to API 23, test:
- [ ] App builds successfully
- [ ] App installs on API 23 device/emulator
- [ ] Camera functionality works (permissions requested)
- [ ] File access works (permissions requested)
- [ ] Share functionality works
- [ ] QR code scanning works
- [ ] Deep linking works
- [ ] All Capacitor plugins work correctly
- [ ] No crashes or permission-related errors
- [ ] APK size is acceptable
## Rollback Plan
If issues arise:
1. Revert `android/variables.gradle` to `minSdkVersion = 22`
2. Rebuild and test
3. Document issues encountered
4. Address issues before retrying upgrade
## Recommendation
### ✅ **Proceed with Upgrade**
**Reasons:**
1. **Minimal Code Impact:** Only one line needs to change
2. **Already Compatible:** App uses Capacitor which handles runtime permissions
3. **Device Impact:** Negligible (~0.1% of devices)
4. **Capacitor Compliance:** Officially meets Capacitor 6 requirements
5. **Future-Proofing:** Better alignment with modern Android development
**Timeline:**
- **Low Risk:** Can be done anytime
- **Recommended:** Before implementing SharedImagePlugin (cleaner baseline)
- **Testing:** 1-2 hours of testing on API 23+ devices
## Migration Steps
1. **Update Build Configuration:**
```bash
# Edit android/variables.gradle
minSdkVersion = 23
```
2. **Sync Gradle:**
```bash
cd android
./gradlew clean
```
3. **Build and Test:**
```bash
npm run build:android:test
# Test on API 23+ device/emulator
```
4. **Verify Permissions:**
- Test camera access
- Test file access
- Verify permission dialogs appear
5. **Update Documentation:**
- Update any docs mentioning API 22
- Update implementation plan
## Summary
| Aspect | Impact | Status |
|--------|--------|--------|
| **Code Changes** | None required | ✅ Safe |
| **Dependencies** | All compatible | ✅ Safe |
| **Permissions** | Already handled | ✅ Safe |
| **Device Coverage** | ~0.1% loss | ⚠️ Minimal |
| **Build Config** | 1 line change | ✅ Simple |
| **Testing** | Standard testing | ✅ Required |
| **Risk Level** | Low | ✅ Low Risk |
**Final Recommendation:** Proceed with upgrade. The benefits (Capacitor compliance, future-proofing) outweigh the minimal risks (negligible device loss, no code changes needed).

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,283 @@
# iOS Share Extension Improvements
**Date:** 2025-11-24
**Purpose:** Explore alternatives to improve user experience by eliminating interstitial UI and simplifying app launch mechanism
## Current Implementation Issues
1. **Interstitial UI**: Users see `SLComposeServiceViewController` with a "Post" button before the app opens
2. **Deep Link Dependency**: App relies on deep link (`timesafari://shared-photo`) to detect shared images, even though data is already in App Group
## Improvement 1: Skip Interstitial UI
### Current Approach
- Uses `SLComposeServiceViewController` which shows a UI with "Post" button
- User must tap "Post" to proceed
### Alternative: Custom UIViewController (Headless Processing)
Replace `SLComposeServiceViewController` with a custom `UIViewController` that:
- Processes the image immediately in `viewDidLoad`
- Shows no UI (or minimal loading indicator)
- Opens the app automatically
**Implementation:**
```swift
import UIKit
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari.share"
private let sharedPhotoBase64Key = "sharedPhotoBase64"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
override func viewDidLoad() {
super.viewDidLoad()
// Process image immediately without showing UI
processAndOpenApp()
}
private func processAndOpenApp() {
guard let extensionContext = extensionContext,
let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
processSharedImage(from: inputItems) { [weak self] success in
guard let self = self else {
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
if success {
self.openMainApp()
}
// Complete immediately - no UI shown
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
// ... (same implementation as current)
}
private func openMainApp() {
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
}
extensionContext?.open(url, completionHandler: nil)
}
}
```
**Info.plist Changes:**
- Already configured correctly with `NSExtensionPrincipalClass`
- No storyboard needed (already removed)
**Benefits:**
- ✅ No interstitial UI - app opens immediately
- ✅ Faster user experience
- ✅ More seamless integration
**Considerations:**
- ⚠️ User has less control (can't cancel easily)
- ⚠️ No visual feedback during processing (could add minimal loading indicator)
- ⚠️ Apple guidelines: Extensions should provide value even if they don't open the app
## Improvement 2: Direct App Launch Without Deep Link
### Current Approach
- Share Extension stores data in App Group UserDefaults
- Share Extension opens app via deep link (`timesafari://shared-photo`)
- App receives deep link → checks App Group → processes image
### Alternative: App Lifecycle Detection
Instead of using deep links, the app can check for shared data when it becomes active:
**Option A: Check on App Activation**
```swift
// In AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
// Check for shared image from Share Extension
if let sharedData = getSharedImageData() {
// Store in temp file for JS to read
writeSharedImageToTempFile(sharedData)
// Navigate to shared-photo route directly
// This would need to be handled in JS layer
}
}
```
**Option B: Use Notification (More Reliable)**
```swift
// In ShareViewController.swift (after storing data)
private func openMainApp() {
// Store a flag that image is ready
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
userDefaults.set(true, forKey: "sharedPhotoReady")
userDefaults.synchronize()
// Open app (can use any URL scheme or even just launch the app)
guard let url = URL(string: "timesafari://") 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
}
}
// In AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
let appGroupIdentifier = "group.app.timesafari.share"
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
// Check if shared photo is ready
if userDefaults.bool(forKey: "sharedPhotoReady") {
userDefaults.removeObject(forKey: "sharedPhotoReady")
userDefaults.synchronize()
// Process shared image
if let sharedData = getSharedImageData() {
writeSharedImageToTempFile(sharedData)
// Trigger JS to check for shared image
// This could be done via Capacitor App plugin or custom event
}
}
}
```
**Option C: Check on App Launch (Most Direct)**
```swift
// In AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Check for shared image immediately on launch
checkForSharedImageOnLaunch()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Also check when app becomes active (in case it was already running)
checkForSharedImageOnLaunch()
}
private func checkForSharedImageOnLaunch() {
if let sharedData = getSharedImageData() {
writeSharedImageToTempFile(sharedData)
// Post a notification or use Capacitor to notify JS
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
}
}
```
**JavaScript Integration:**
```typescript
// In main.capacitor.ts
import { App } from '@capacitor/app';
// Listen for app becoming active
App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
// Check for shared image when app becomes active
await checkAndStoreNativeSharedImage();
}
});
// Also check on initial load
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios') {
checkAndStoreNativeSharedImage().then(result => {
if (result.success) {
// Navigate to shared-photo route
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
}
});
}
```
**Benefits:**
- ✅ No deep link routing needed
- ✅ More direct data flow
- ✅ App can detect shared content even if it was already running
- ✅ Simpler URL scheme handling
**Considerations:**
- ⚠️ Need to ensure app checks on both launch and activation
- ⚠️ May need to handle race conditions (app launching vs. share extension writing)
- ⚠️ Still need some way to open the app (minimal URL scheme still required)
## Recommended Approach
**Best of Both Worlds:**
1. **Use Custom UIViewController** (Improvement 1) - Eliminates interstitial UI
2. **Use App Lifecycle Detection** (Improvement 2, Option C) - Direct data flow
**Combined Implementation:**
```swift
// ShareViewController.swift - Custom UIViewController
class ShareViewController: UIViewController {
// Process immediately in viewDidLoad
// Store data in App Group
// Open app with minimal URL (just "timesafari://")
}
// AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
// Check for shared image
// If found, write to temp file and let JS handle navigation
}
```
**JavaScript:**
```typescript
// Check on app activation
App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
const result = await checkAndStoreNativeSharedImage();
if (result.success) {
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
}
}
});
```
This approach:
- ✅ No interstitial UI
- ✅ No deep link routing complexity
- ✅ Direct data flow via App Group
- ✅ Works whether app is running or launching fresh

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.share`
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.share` (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.share")
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.share`
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,528 @@
# Shared Image Plugin Implementation Plan
**Date:** 2025-12-03 15:40:38 PST
**Status:** Planning
**Goal:** Replace temp file approach with native Capacitor plugins for iOS and Android
## Minimum OS Version Compatibility Analysis
### Current Project Configuration:
- **iOS Deployment Target**: 13.0 (Podfile and Xcode project)
- **Android minSdkVersion**: 23 (API 23 - Android 6.0 Marshmallow) ✅ **Upgraded**
- **Capacitor Version**: 6.2.0
### Capacitor 6 Requirements:
- **iOS**: Requires iOS 13.0+ ✅ **Compatible** (current: 13.0)
- **Android**: Requires API 23+ ✅ **Compatible** (current: API 23)
### Plugin API Compatibility:
#### iOS Plugin APIs:
-`CAPPlugin` base class: Available in iOS 13.0+ (Capacitor requirement)
-`CAPPluginCall`: Available in iOS 13.0+ (Capacitor requirement)
-`UserDefaults(suiteName:)`: Available since iOS 8.0 (well below iOS 13.0)
-`@objc` annotations: Available since iOS 8.0
- ✅ Swift 5.0: Compatible with iOS 13.0+
**Conclusion**: iOS 13.0 is fully compatible with the plugin implementation. **No iOS version update required.**
#### Android Plugin APIs:
-`Plugin` base class: Available in API 21+ (Capacitor requirement)
-`PluginCall`: Available in API 21+ (Capacitor requirement)
-`SharedPreferences`: Available since API 1 (works on all Android versions)
-`@CapacitorPlugin` annotation: Available in API 21+ (Capacitor requirement)
-`@PluginMethod` annotation: Available in API 21+ (Capacitor requirement)
**Conclusion**: Android API 23 is fully compatible with the plugin implementation and officially meets Capacitor 6 requirements. ✅ **No Android version concerns.**
### Share Extension Compatibility:
- **iOS Share Extension**: Uses same deployment target as main app (iOS 13.0)
- **App Group**: Available since iOS 8.0, fully compatible
- No additional version requirements for share extension functionality
## Overview
This document outlines the migration from the current temp file approach to implementing dedicated Capacitor plugins for handling shared images. This will eliminate file I/O, polling, and timing issues, providing a more direct and reliable native-to-JS bridge.
## Current Implementation Issues
### Temp File Approach Problems:
1. **Timing Issues**: Requires polling with exponential backoff to wait for file creation
2. **Race Conditions**: File may not exist when JS checks, or may be read multiple times
3. **File Management**: Need to delete temp files after reading to prevent re-processing
4. **Platform Differences**: Different directories (Documents vs Data) add complexity
5. **Error Handling**: File I/O errors can be hard to debug
6. **Performance**: File system operations are slower than direct native calls
## Proposed Solution: Capacitor Plugins
### Benefits:
- ✅ Direct native-to-JS communication (no file I/O)
- ✅ Synchronous/async method calls (no polling needed)
- ✅ Type-safe TypeScript interfaces
- ✅ Better error handling and debugging
- ✅ Lower latency
- ✅ More maintainable and follows Capacitor best practices
## Implementation Layout
### 1. iOS Plugin Implementation
#### 1.1 Create iOS Plugin File
**Location:** `ios/App/App/SharedImagePlugin.swift`
**Structure:**
```swift
import Foundation
import Capacitor
@objc(SharedImagePlugin)
public class SharedImagePlugin: CAPPlugin {
private let appGroupIdentifier = "group.app.timesafari.share"
@objc func getSharedImage(_ call: CAPPluginCall) {
// Read from App Group UserDefaults
// Return base64 and fileName
// Clear data after reading
}
@objc func hasSharedImage(_ call: CAPPluginCall) {
// Check if shared image exists without reading it
// Useful for quick checks
}
}
```
**Key Points:**
- Use existing `getSharedImageData()` logic from AppDelegate
- Return data as JSObject with `base64` and `fileName` keys
- Clear UserDefaults after reading to prevent re-reading
- Handle errors gracefully with `call.reject()`
- **Version Compatibility**: Works with iOS 13.0+ (current deployment target)
#### 1.2 Register Plugin in iOS
**Location:** `ios/App/App/AppDelegate.swift`
**Changes:**
- Remove `writeSharedImageToTempFile()` method
- Remove temp file writing from `application(_:open:options:)`
- Remove temp file writing from `checkForSharedImageOnActivation()`
- Keep `getSharedImageData()` method (or move to plugin)
- Plugin auto-registers via Capacitor's plugin system
**Note:** Capacitor plugins are auto-discovered if they follow naming conventions and are in the app bundle.
### 2. Android Plugin Implementation
#### 2.1 Create Android Plugin File
**Location:** `android/app/src/main/java/app/timesafari/sharedimage/SharedImagePlugin.java`
**Structure:**
```java
package app.timesafari.sharedimage;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "SharedImage")
public class SharedImagePlugin extends Plugin {
@PluginMethod
public void getSharedImage(PluginCall call) {
// Read from SharedPreferences or Intent extras
// Return base64 and fileName
// Clear data after reading
}
@PluginMethod
public void hasSharedImage(PluginCall call) {
// Check if shared image exists without reading it
}
}
```
**Key Points:**
- Use SharedPreferences to store shared image data between share intent and plugin call
- Store base64 and fileName when processing share intent
- Read and clear in `getSharedImage()` method
- Handle Intent extras if app was just launched
- **Version Compatibility**: Works with Android API 22+ (current minSdkVersion)
#### 2.2 Update MainActivity
**Location:** `android/app/src/main/java/app/timesafari/MainActivity.java`
**Changes:**
- Remove `writeSharedImageToTempFile()` method
- Remove `TEMP_FILE_NAME` constant
- Update `processSharedImage()` to store in SharedPreferences instead of file
- Register plugin: `registerPlugin(SharedImagePlugin.class);`
- Store shared image data in SharedPreferences when processing share intent
**SharedPreferences Approach:**
```java
// In processSharedImage():
SharedPreferences prefs = getSharedPreferences("shared_image", MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.putString("base64", base64String);
editor.putString("fileName", actualFileName);
editor.putBoolean("hasSharedImage", true);
editor.apply();
```
### 3. TypeScript/JavaScript Integration
#### 3.1 Create TypeScript Plugin Definition
**Location:** `src/plugins/SharedImagePlugin.ts` (new file)
**Structure:**
```typescript
import { registerPlugin } from '@capacitor/core';
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
getSharedImage(): Promise<SharedImageResult | null>;
hasSharedImage(): Promise<{ hasImage: boolean }>;
}
const SharedImage = registerPlugin<SharedImagePlugin>('SharedImage', {
web: () => import('./SharedImagePlugin.web').then(m => new m.SharedImagePluginWeb()),
});
export * from './definitions';
export { SharedImage };
```
#### 3.2 Create Web Implementation (for development)
**Location:** `src/plugins/SharedImagePlugin.web.ts` (new file)
**Structure:**
```typescript
import { WebPlugin } from '@capacitor/core';
import type { SharedImagePlugin, SharedImageResult } from './definitions';
export class SharedImagePluginWeb extends WebPlugin implements SharedImagePlugin {
async getSharedImage(): Promise<SharedImageResult | null> {
// Return null for web platform
return null;
}
async hasSharedImage(): Promise<{ hasImage: boolean }> {
return { hasImage: false };
}
}
```
#### 3.3 Create Type Definitions
**Location:** `src/plugins/definitions.ts` (new file)
**Structure:**
```typescript
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
getSharedImage(): Promise<SharedImageResult | null>;
hasSharedImage(): Promise<{ hasImage: boolean }>;
}
```
#### 3.4 Update main.capacitor.ts
**Location:** `src/main.capacitor.ts`
**Changes:**
- Remove `pollForFileExistence()` function
- Remove temp file reading logic from `checkAndStoreNativeSharedImage()`
- Replace with direct plugin call:
```typescript
async function checkAndStoreNativeSharedImage(): Promise<{
success: boolean;
fileName?: string;
}> {
if (isProcessingSharedImage) {
logger.debug("[Main] ⏸️ Shared image processing already in progress, skipping");
return { success: false };
}
isProcessingSharedImage = true;
try {
if (!Capacitor.isNativePlatform() ||
(Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android")) {
isProcessingSharedImage = false;
return { success: false };
}
// Direct plugin call - no polling needed!
const { SharedImage } = await import('./plugins/SharedImagePlugin');
const result = await SharedImage.getSharedImage();
if (result && result.base64) {
await storeSharedImageInTempDB(result.base64, result.fileName);
isProcessingSharedImage = false;
return { success: true, fileName: result.fileName };
}
isProcessingSharedImage = false;
return { success: false };
} catch (error) {
logger.error("[Main] Error checking for native shared image:", error);
isProcessingSharedImage = false;
return { success: false };
}
}
```
**Remove:**
- `pollForFileExistence()` function (lines 71-98)
- All Filesystem plugin imports related to temp file reading
- Temp file path constants and directory logic
### 4. Data Flow Comparison
#### Current (Temp File) Flow:
```
Share Extension/Intent
Native writes temp file
JS polls for file existence (with retries)
JS reads file via Filesystem plugin
JS parses JSON
JS deletes temp file
JS stores in temp DB
```
#### New (Plugin) Flow:
```
Share Extension/Intent
Native stores in UserDefaults/SharedPreferences
JS calls plugin.getSharedImage()
Native reads and clears data
Native returns data directly
JS stores in temp DB
```
## File Changes Summary
### New Files to Create:
1. `ios/App/App/SharedImagePlugin.swift` - iOS plugin implementation
2. `android/app/src/main/java/app/timesafari/sharedimage/SharedImagePlugin.java` - Android plugin
3. `src/plugins/SharedImagePlugin.ts` - TypeScript plugin registration
4. `src/plugins/SharedImagePlugin.web.ts` - Web fallback implementation
5. `src/plugins/definitions.ts` - TypeScript type definitions
### Files to Modify:
1. `ios/App/App/AppDelegate.swift` - Remove temp file writing
2. `android/app/src/main/java/app/timesafari/MainActivity.java` - Remove temp file writing, add SharedPreferences
3. `src/main.capacitor.ts` - Replace temp file logic with plugin calls
### Files to Remove:
- No files need to be deleted, but code will be removed from existing files
## Implementation Considerations
### 1. Data Storage Strategy
#### iOS:
- **Current**: App Group UserDefaults (already working)
- **Plugin**: Read from same UserDefaults, no changes needed
- **Clearing**: Clear immediately after reading in plugin method
#### Android:
- **Current**: Temp file in app's internal files directory
- **New**: SharedPreferences (persistent key-value store)
- **Alternative**: Could use Intent extras if app is launched fresh, but SharedPreferences is more reliable for backgrounded apps
### 2. Timing and Lifecycle
#### When to Check for Shared Images:
1. **App Launch**: Check in `checkForSharedImageAndNavigate()` (already exists)
2. **App Becomes Active**: Check in `appStateChange` listener (already exists)
3. **Deep Link**: Check in `handleDeepLink()` for empty path URLs (already exists)
#### Plugin Call Timing:
- Plugin calls are synchronous from JS perspective
- No polling needed - native side handles data availability
- If no data exists, plugin returns `null` immediately
### 3. Error Handling
#### Plugin Error Scenarios:
- **No shared image**: Return `null` (not an error)
- **Data corruption**: Return error via `call.reject()`
- **Missing permissions**: Return error (shouldn't happen with App Group/SharedPreferences)
#### JS Error Handling:
- Wrap plugin calls in try-catch
- Log errors appropriately
- Don't crash app if plugin fails
### 4. Backward Compatibility
#### Migration Path:
- Keep temp file code temporarily (commented out) for rollback
- Test thoroughly on both platforms
- Remove temp file code after verification
### 5. Testing Considerations
#### Test Cases:
1. **Share from Photos app** → Verify image appears in app
2. **Share while app is backgrounded** → Verify image appears when app becomes active
3. **Share while app is closed** → Verify image appears on app launch
4. **Multiple rapid shares** → Verify only latest image is processed
5. **Share then close app before processing** → Verify image persists
6. **Share then clear app data** → Verify graceful handling
#### Edge Cases:
- Very large images (memory concerns)
- Multiple images shared simultaneously
- App killed by OS before processing
- Network interruptions during processing
### 6. Performance Considerations
#### Benefits:
- **Latency**: Direct calls vs file I/O (faster)
- **CPU**: No polling overhead
- **Memory**: No temp file storage
- **Battery**: Less file system activity
#### Potential Issues:
- Large base64 strings in memory (same as current approach)
- UserDefaults/SharedPreferences size limits (shouldn't be an issue for single image)
### 7. Type Safety
#### TypeScript Benefits:
- Full type checking for plugin methods
- Autocomplete in IDE
- Compile-time error checking
- Better developer experience
### 8. Plugin Registration
#### iOS:
- Capacitor auto-discovers plugins via naming convention
- Ensure plugin is in app target (not extension target)
- No manual registration needed in AppDelegate
#### Android:
- Register in `MainActivity.onCreate()`:
```java
registerPlugin(SharedImagePlugin.class);
```
### 9. Capacitor Version Compatibility
#### Check Current Version:
- Verify Capacitor version supports custom plugins
- Ensure plugin API hasn't changed
- Test with current Capacitor version first
### 10. Build and Deployment
#### Build Steps:
1. Create plugin files
2. Register Android plugin in MainActivity
3. Update TypeScript code
4. Test on iOS simulator
5. Test on Android emulator
6. Test on physical devices
7. Remove temp file code
8. Update documentation
#### Deployment:
- No changes to build scripts needed
- No changes to CI/CD needed
- No changes to app configuration needed
## Migration Steps
### Phase 1: Create Plugins (Non-Breaking)
1. Create iOS plugin file
2. Create Android plugin file
3. Create TypeScript definitions
4. Register Android plugin
5. Test plugins independently (don't use in main code yet)
### Phase 2: Update JS Integration (Breaking)
1. Create TypeScript plugin wrapper
2. Update `checkAndStoreNativeSharedImage()` to use plugin
3. Remove temp file reading logic
4. Test on both platforms
### Phase 3: Cleanup Native Code (Breaking)
1. Remove temp file writing from iOS AppDelegate
2. Remove temp file writing from Android MainActivity
3. Update to use SharedPreferences on Android
4. Test thoroughly
### Phase 4: Final Cleanup
1. Remove `pollForFileExistence()` function
2. Remove Filesystem imports related to temp files
3. Update comments and documentation
4. Final testing
## Rollback Plan
If issues arise:
1. Revert JS changes to use temp file approach
2. Re-enable temp file writing in native code
3. Keep plugins for future migration attempt
4. Document issues encountered
## Success Criteria
✅ Plugin methods work on both iOS and Android
✅ No polling or file I/O needed
✅ Shared images appear correctly in app
✅ No memory leaks or performance issues
✅ Error handling works correctly
✅ All test cases pass
✅ Code is cleaner and more maintainable
## Additional Notes
### iOS App Group:
- Current App Group ID: `group.app.timesafari.share`
- Ensure plugin has access to same App Group
- Share Extension already writes to this App Group
### Android Share Intent:
- Current implementation handles `ACTION_SEND` and `ACTION_SEND_MULTIPLE`
- SharedPreferences key: `shared_image` (or similar)
- Store both base64 and fileName
### Future Enhancements:
- Consider adding event listeners for real-time notifications
- Could add method to clear shared image without reading
- Could add method to get image metadata without full data
## References
- [Capacitor Plugin Development Guide](https://capacitorjs.com/docs/plugins)
- Existing plugin example: `SafeAreaPlugin.java`
- Current temp file implementation: `main.capacitor.ts` lines 166-271
- iOS AppDelegate: `ios/App/App/AppDelegate.swift`
- Android MainActivity: `android/app/src/main/java/app/timesafari/MainActivity.java`

View File

@@ -0,0 +1,329 @@
# Shared Image Plugin - Pre-Implementation Decision Checklist
**Date:** 2025-12-03
**Status:** Pre-Implementation Planning
**Purpose:** Identify and document decisions needed before implementing SharedImagePlugin
## ✅ Completed Decisions
### 1. Minimum OS Versions
-**iOS**: Keep at 13.0 (no changes needed)
-**Android**: Upgraded from API 22 to API 23 (completed)
-**Rationale**: Meets Capacitor 6 requirements, minimal device impact
### 2. Data Storage Strategy
-**iOS**: Use App Group UserDefaults (already implemented in Share Extension)
-**Android**: Use SharedPreferences (to be implemented)
-**Rationale**: Direct, efficient, no file I/O needed
## 🔍 Decisions Needed Before Implementation
### 1. Plugin Method Design
#### Decision: What methods should the plugin expose?
**Options:**
- **Option A (Minimal)**: Only `getSharedImage()` - read and clear in one call
- **Option B (Recommended)**: `getSharedImage()` + `hasSharedImage()` - allows checking without reading
- **Option C (Extended)**: Add `clearSharedImage()` - explicit clearing without reading
**Recommendation:** **Option B**
- `getSharedImage()`: Returns `{ base64: string, fileName: string } | null`
- `hasSharedImage()`: Returns `{ hasImage: boolean }` - useful for quick checks
**Decision Needed:** ✅ Confirm Option B or choose alternative
---
### 2. Error Handling Strategy
#### Decision: How should the plugin handle errors?
**Options:**
- **Option A**: Return `null` for all errors (no shared image = no error)
- **Option B**: Use `call.reject()` for actual errors, return `null` only when no image exists
- **Option C**: Return error object in result: `{ error: string } | { base64: string, fileName: string }`
**Recommendation:** **Option B**
- `getSharedImage()` returns `null` when no image exists (normal case)
- `call.reject()` for actual errors (UserDefaults unavailable, data corruption, etc.)
- Clear distinction between "no data" (normal) vs "error" (exceptional)
**Decision Needed:** ✅ Confirm Option B or choose alternative
---
### 3. Data Clearing Strategy
#### Decision: When should shared image data be cleared?
**Current Behavior (temp file approach):**
- Data cleared after reading (immediate)
**Options:**
- **Option A**: Clear immediately after reading (current behavior)
- **Option B**: Clear on next read (allow re-reading until consumed)
- **Option C**: Clear after successful storage in temp DB (JS confirms receipt)
**Recommendation:** **Option A** (immediate clearing)
- Prevents accidental re-reading
- Simpler implementation
- Matches current behavior
- If JS fails to store, user can share again
**Decision Needed:** ✅ Confirm Option A or choose alternative
---
### 4. iOS Plugin Registration
#### Decision: How should the iOS plugin be registered?
**Options:**
- **Option A**: Auto-discovery (Capacitor finds plugins by naming convention)
- **Option B**: Manual registration in AppDelegate
- **Option C**: Hybrid (auto-discovery with manual registration as fallback)
**Recommendation:** **Option A** (auto-discovery)
- Follows Capacitor best practices
- Less code to maintain
- Other plugins in project use auto-discovery (SafeAreaPlugin uses manual, but that's older pattern)
**Note:** Need to verify plugin naming convention:
- Class name: `SharedImagePlugin`
- File name: `SharedImagePlugin.swift`
- Location: `ios/App/App/SharedImagePlugin.swift`
**Decision Needed:** ✅ Confirm Option A, or if auto-discovery doesn't work, use Option B
---
### 5. TypeScript Interface Design
#### Decision: What should the TypeScript interface look like?
**Proposed Interface:**
```typescript
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
getSharedImage(): Promise<SharedImageResult | null>;
hasSharedImage(): Promise<{ hasImage: boolean }>;
}
```
**Questions:**
- Should `fileName` be optional? (Currently always provided, but could be empty string)
- Should we include metadata (image size, MIME type)?
- Should `hasSharedImage()` return more info (like fileName without reading)?
**Recommendation:** Keep simple for now:
- `fileName` is always a string (may be default "shared-image.jpg")
- No metadata initially (can add later if needed)
- `hasSharedImage()` only returns boolean (keep it lightweight)
**Decision Needed:** ✅ Confirm interface design or request changes
---
### 6. Android Data Storage Timing
#### Decision: When should Android store shared image data in SharedPreferences?
**Current Flow:**
1. Share intent received in MainActivity
2. Image processed and written to temp file
3. JS reads temp file
**New Flow Options:**
- **Option A**: Store in SharedPreferences immediately when share intent received (in `processSharedImage()`)
- **Option B**: Store when plugin is first called (lazy loading)
- **Option C**: Store in both places during transition (backward compatibility)
**Recommendation:** **Option A** (immediate storage)
- Data available immediately when plugin is called
- No timing issues
- Matches iOS pattern (data stored by Share Extension)
**Implementation:**
- Update `processSharedImage()` in MainActivity to store in SharedPreferences
- Remove temp file writing
- Plugin reads from SharedPreferences
**Decision Needed:** ✅ Confirm Option A
---
### 7. Migration Strategy
#### Decision: How to handle the transition from temp file to plugin?
**Options:**
- **Option A (Clean Break)**: Remove temp file code immediately, use plugin only
- **Option B (Gradual)**: Support both approaches temporarily, remove temp file later
- **Option C (Feature Flag)**: Use feature flag to switch between approaches
**Recommendation:** **Option A** (clean break)
- Simpler implementation
- Less code to maintain
- Temp file approach is buggy anyway (why we're replacing it)
- Can rollback via git if needed
**Rollback Plan:**
- Keep temp file code in git history
- If plugin has issues, can revert commit
- Test thoroughly before removing temp file code
**Decision Needed:** ✅ Confirm Option A
---
### 8. Plugin Naming
#### Decision: What should the plugin be named?
**Options:**
- **Option A**: `SharedImage` (matches file/class names)
- **Option B**: `SharedImagePlugin` (more explicit)
- **Option C**: `NativeShare` (more generic, could handle other share types)
**Recommendation:** **Option A** (`SharedImage`)
- Matches Capacitor naming conventions (plugins are referenced without "Plugin" suffix)
- Examples: `Capacitor.Plugins.Camera`, `Capacitor.Plugins.Filesystem`
- TypeScript: `SharedImage.getSharedImage()`
**Decision Needed:** ✅ Confirm Option A
---
### 9. iOS: Reuse getSharedImageData() or Move to Plugin?
#### Decision: Should the plugin reuse AppDelegate's `getSharedImageData()` or implement its own?
**Current Code:**
- `AppDelegate.getSharedImageData()` exists and works
- Reads from App Group UserDefaults
- Clears data after reading
**Options:**
- **Option A**: Plugin calls `getSharedImageData()` from AppDelegate
- **Option B**: Plugin implements its own logic (duplicate code)
- **Option C**: Move `getSharedImageData()` to a shared utility, both use it
**Recommendation:** **Option C** (shared utility)
- DRY principle
- Single source of truth
- But: May be overkill for simple logic
**Alternative Recommendation:** **Option B** (plugin implements own logic)
- Plugin is self-contained
- No dependency on AppDelegate
- Logic is simple (just UserDefaults read/clear)
- Can remove `getSharedImageData()` from AppDelegate after migration
**Decision:****Option C** (shared utility) - **CONFIRMED**
- Create shared utility for reading from App Group UserDefaults
- Both AppDelegate and plugin use the shared utility
- Single source of truth for shared image data access
---
### 10. Android: SharedPreferences Key Names
#### Decision: What keys should be used in SharedPreferences?
**Proposed Keys:**
- `shared_image_base64` - Base64 string
- `shared_image_file_name` - File name
- `shared_image_ready` - Boolean flag (optional, for quick checks)
**Alternative:**
- Use a single JSON object: `shared_image_data` = `{ base64: "...", fileName: "..." }`
**Recommendation:** Separate keys (first option)
- Simpler to read/write
- No JSON parsing needed
- Matches iOS pattern (separate UserDefaults keys)
- Flag is optional but useful for `hasSharedImage()`
**Decision Needed:** ✅ Confirm key naming or request changes
---
### 11. Testing Strategy
#### Decision: What testing approach should we use?
**Options:**
- **Option A**: Manual testing only
- **Option B**: Manual + automated unit tests for plugin methods
- **Option C**: Manual + integration tests
**Recommendation:** **Option A** (manual testing) for now
- Plugins are hard to unit test (require native environment)
- Manual testing is sufficient for initial implementation
- Can add automated tests later if needed
**Test Scenarios:**
1. Share image from Photos app → Verify appears in app
2. Share while app backgrounded → Verify appears when app becomes active
3. Share while app closed → Verify appears on app launch
4. Multiple rapid shares → Verify only latest is processed
5. Share then close app before processing → Verify data persists
6. Share then clear app data → Verify graceful handling
**Decision Needed:** ✅ Confirm testing approach
---
### 12. Documentation Updates
#### Decision: What documentation needs updating?
**Files to Update:**
- ✅ Implementation plan (this document)
- ⚠️ `doc/native-share-target-implementation.md` - Update to reflect plugin approach
- ⚠️ `doc/ios-share-implementation-status.md` - Mark plugin as implemented
- ⚠️ Code comments in `main.capacitor.ts` - Update to reflect plugin usage
**Decision Needed:** ✅ Confirm documentation update list
---
## Summary of Decisions Needed
| # | Decision | Recommendation | Status |
|---|----------|----------------|--------|
| 1 | Plugin Methods | Option B: `getSharedImage()` + `hasSharedImage()` | ✅ Confirmed |
| 2 | Error Handling | Option B: `null` for no data, `reject()` for errors | ✅ Confirmed |
| 3 | Data Clearing | Option A: Clear immediately after reading | ✅ Confirmed |
| 4 | iOS Registration | Option A: Auto-discovery | ✅ Confirmed |
| 5 | TypeScript Interface | Proposed interface (see above) | ✅ Confirmed |
| 6 | Android Storage Timing | Option A: Store immediately on share intent | ✅ Confirmed |
| 7 | Migration Strategy | Option A: Clean break, remove temp file code | ✅ Confirmed |
| 8 | Plugin Naming | Option A: `SharedImage` | ✅ Confirmed |
| 9 | iOS Code Reuse | Option C: Shared utility | ✅ Confirmed |
| 10 | Android Key Names | Separate keys: `shared_image_base64`, `shared_image_file_name` | ✅ Confirmed |
| 11 | Testing Strategy | Option A: Manual testing | ✅ Confirmed |
| 12 | Documentation | Update listed files | ✅ Confirmed |
| - | Multiple Images | Single image only (SharedPhotoView requirement) | ✅ Confirmed |
| - | Backward Compatibility | No temp file backward compatibility | ✅ Confirmed |
## Next Steps
1. **Review this checklist** and confirm or modify recommendations
2. **Make decisions** on all pending items
3. **Update implementation plan** with confirmed decisions
4. **Begin implementation** with clear specifications
## Questions to Consider
- Are there any edge cases not covered?
- Should we support multiple images (currently only first image)?
- Should we add image metadata (size, MIME type) in the future?
- Do we need backward compatibility with temp file approach?
- Should plugin methods be synchronous or async? (Capacitor plugins are async by default)

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, ); }; };
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.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,39 @@
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>"; };
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = "<group>"; };
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImagePlugin.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 +97,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D22ED456DE00824752 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -56,6 +119,7 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
504EC3051FED79650016851F /* Products */,
BA325FFCDCE8D334E5C7AEBE /* Pods */,
4B546315E668C7A13939F417 /* Frameworks */,
@@ -66,6 +130,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -73,6 +138,9 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -108,16 +176,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 +217,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1630;
TargetAttributes = {
504EC3031FED79650016851F = {
@@ -133,6 +225,9 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
C86585D42ED456DE00824752 = {
CreatedOnToolsVersion = 26.1.1;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -149,6 +244,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
);
};
/* End PBXProject section */
@@ -167,6 +263,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D32ED456DE00824752 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -253,12 +356,29 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C8C56E142EE0474B00737D0E /* SharedImageUtility.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 +522,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 = 46;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +534,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.5;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -429,8 +550,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 = 46;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +562,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -450,6 +572,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 = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.1.5;
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 = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.1.5;
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 +667,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.share</string>
</array>
</dict>
</plist>

View File

@@ -12,9 +12,49 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
//let sqlite = SQLite()
//sqlite.initialize()
// Register SharedImage plugin manually after bridge is ready
// Try multiple times with increasing delays to ensure bridge is initialized
var attempts = 0
let maxAttempts = 5
func tryRegister() {
attempts += 1
if registerSharedImagePlugin() {
print("[AppDelegate] ✅ Plugin registration successful on attempt \(attempts)")
} else if attempts < maxAttempts {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) {
tryRegister()
}
} else {
print("[AppDelegate] ⚠️ Failed to register plugin after \(maxAttempts) attempts")
}
}
// Start registration attempts
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
tryRegister()
}
// Override point for customization after application launch.
return true
}
@discardableResult
private func registerSharedImagePlugin() -> Bool {
guard let window = self.window,
let bridgeVC = window.rootViewController as? CAPBridgeViewController,
let bridge = bridgeVC.bridge else {
return false
}
// Create plugin instance
// The @objc(SharedImage) annotation makes it available as "SharedImage" to Objective-C
// which matches the JavaScript registration name
let pluginInstance = SharedImagePlugin()
bridge.registerPluginInstance(pluginInstance)
print("[AppDelegate] ✅ Registered SharedImagePlugin (exposed as 'SharedImage' via @objc annotation)")
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
@@ -32,6 +72,26 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
// Check for shared image from Share Extension when app becomes active
checkForSharedImageOnActivation()
}
/**
* Check for shared image when app launches or becomes active
* This allows the app to detect shared images without requiring a deep link
* Note: JavaScript will read the shared image via SharedImagePlugin, so we just check the flag
*/
private func checkForSharedImageOnActivation() {
// Check if shared photo is ready
if SharedImageUtility.isSharedPhotoReady() {
// Clear the flag
SharedImageUtility.clearSharedPhotoReadyFlag()
// Post notification for JavaScript to handle navigation
// JavaScript will read the shared image via SharedImagePlugin
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
}
}
func applicationWillTerminate(_ application: UIApplication) {
@@ -41,6 +101,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// 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
// Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript
// via the appUrlOpen listener in main.capacitor.ts
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
@@ -50,5 +112,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

View File

@@ -0,0 +1,66 @@
//
// SharedImagePlugin.swift
// App
//
// Capacitor plugin for accessing shared image data from Share Extension
//
import Foundation
import Capacitor
@objc(SharedImage)
public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - CAPBridgedPlugin Conformance
public var identifier: String {
return "SharedImage"
}
public var jsName: String {
return "SharedImage"
}
public var pluginMethods: [CAPPluginMethod] {
return [
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise)
]
}
// MARK: - Plugin Methods
/**
* Get shared image data from App Group UserDefaults
* Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading
*/
@objc public func getSharedImage(_ call: CAPPluginCall) {
guard let sharedData = SharedImageUtility.getSharedImageData() else {
// No shared image exists - return null (not an error)
call.resolve([
"base64": NSNull(),
"fileName": NSNull()
])
return
}
// Return the shared image data
call.resolve([
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
])
}
/**
* Check if shared image exists without reading it
* Useful for quick checks before calling getSharedImage()
*/
@objc public func hasSharedImage(_ call: CAPPluginCall) {
let hasImage = SharedImageUtility.hasSharedImage()
call.resolve([
"hasImage": hasImage
])
}
}

View File

@@ -0,0 +1,107 @@
//
// SharedImageUtility.swift
// App
//
// Shared utility for accessing shared image data from App Group container
// Images are stored as files in the App Group container to avoid UserDefaults size limits
// Used by both AppDelegate and SharedImagePlugin
//
import Foundation
public class SharedImageUtility {
private static let appGroupIdentifier = "group.app.timesafari.share"
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
private static let sharedPhotoReadyKey = "sharedPhotoReady"
/// Get the App Group container URL for accessing shared files
private static var appGroupContainerURL: URL? {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}
/**
* Get shared image data from App Group container file
* All images are stored as files for consistency and to avoid UserDefaults size limits
* Clears the data after reading to prevent re-reading
*
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/
static func getSharedImageData() -> [String: String]? {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return nil
}
// Get file path and filename from UserDefaults
guard let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
let containerURL = appGroupContainerURL else {
return nil
}
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
let fileURL = containerURL.appendingPathComponent(filePath)
// Read image data from file
guard let imageData = try? Data(contentsOf: fileURL) else {
return nil
}
// Convert file data to base64 for JavaScript consumption
let base64String = imageData.base64EncodedString()
// Clear the shared data after reading
userDefaults.removeObject(forKey: sharedPhotoFilePathKey)
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
// Remove the file
try? FileManager.default.removeItem(at: fileURL)
userDefaults.synchronize()
return ["base64": base64String, "fileName": fileName]
}
/**
* Check if shared image exists without reading it
*
* @returns true if shared image file exists, false otherwise
*/
static func hasSharedImage() -> Bool {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
let containerURL = appGroupContainerURL else {
return false
}
let fileURL = containerURL.appendingPathComponent(filePath)
return FileManager.default.fileExists(atPath: fileURL.path)
}
/**
* Check if shared photo ready flag is set
* This flag is set by the Share Extension when image is ready
*
* @returns true if flag is set, false otherwise
*/
static func isSharedPhotoReady() -> Bool {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return false
}
return userDefaults.bool(forKey: sharedPhotoReadyKey)
}
/**
* Clear the shared photo ready flag
* Called after processing the shared image
*/
static func clearSharedPhotoReadyFlag() {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
userDefaults.removeObject(forKey: sharedPhotoReadyKey)
userDefaults.synchronize()
}
}

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,207 @@
//
// ShareViewController.swift
// TimeSafariShareExtension
//
// Created by Aardimus on 11/24/25.
//
import UIKit
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari.share"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
private let sharedImageFileName = "shared-image"
/// Get the App Group container URL for storing shared files
private var appGroupContainerURL: URL? {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}
override func viewDidLoad() {
super.viewDidLoad()
// Set a minimal background (transparent or loading indicator)
view.backgroundColor = .systemBackground
// Process image immediately without showing UI
processAndOpenApp()
}
private func processAndOpenApp() {
// extensionContext is automatically available on UIViewController when used as extension principal class
guard let context = extensionContext,
let inputItems = context.inputItems as? [NSExtensionItem] else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
processSharedImage(from: inputItems) { [weak self] success in
guard let self = self, let context = self.extensionContext else {
return
}
if success {
// Set flag that shared photo is ready
self.setSharedPhotoReadyFlag()
// Open the main app (using minimal URL - app will detect shared data on activation)
self.openMainApp()
}
// Complete immediately - no UI shown
context.completeRequest(returningItems: [], completionHandler: nil)
}
}
private func setSharedPhotoReadyFlag() {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
userDefaults.set(true, forKey: "sharedPhotoReady")
userDefaults.synchronize()
}
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 {
// Skip non-image attachments
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
continue
}
// Try to load raw data first to preserve original format
// This preserves the original image format without conversion
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
guard let self = self else {
completion(false)
return
}
if error != nil {
completion(false)
return
}
// Handle different image data types
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
var imageData: Data?
var fileName: String = "shared-image"
if let url = data as? URL {
// Most common case: Image provided as file URL - read raw data to preserve format
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
// Read raw data directly to preserve original format
imageData = try? Data(contentsOf: url)
fileName = url.lastPathComponent
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
imageData = image.pngData()
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
}
} else if let data = data as? Data {
// Less common: Image provided as raw Data - use directly to preserve format
imageData = data
fileName = attachment.suggestedName ?? "shared-image"
}
guard let finalImageData = imageData else {
completion(false)
return
}
// Store image as file in App Group container
if self.storeImageData(finalImageData, fileName: fileName) {
completion(true)
} else {
completion(false)
}
}
return // Process only the first image
}
}
// No image found
completion(false)
}
/// Helper to get filename with a new extension, preserving base name
private func getFileNameWithExtension(_ originalName: String, newExtension: String) -> String {
if let nameWithoutExt = originalName.components(separatedBy: ".").first, !nameWithoutExt.isEmpty {
return "\(nameWithoutExt).\(newExtension)"
}
return "shared-image.\(newExtension)"
}
/// Store image data as a file in the App Group container
/// All images are stored as files regardless of size for consistency and simplicity
/// Returns true if successful, false otherwise
private func storeImageData(_ imageData: Data, fileName: String) -> Bool {
guard let containerURL = appGroupContainerURL else {
return false
}
// Create file URL in the container using the actual filename
// Extract extension from fileName if present, otherwise use sharedImageFileName
let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName
let fileURL = containerURL.appendingPathComponent(actualFileName)
// Remove old file if it exists
try? FileManager.default.removeItem(at: fileURL)
// Write image data to file
do {
try imageData.write(to: fileURL)
} catch {
return false
}
// Store file path and filename in UserDefaults (small data, safe to store)
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return false
}
// Store relative path and filename
userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey)
userDefaults.set(fileName, forKey: sharedPhotoFileNameKey)
// Clean up any old base64 data that might exist
userDefaults.removeObject(forKey: "sharedPhotoBase64")
userDefaults.synchronize()
return true
}
private func openMainApp() {
// Open the main app with minimal URL - app will detect shared data on activation
guard let url = URL(string: "timesafari://") 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)
}
}

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.share</string>
</array>
</dict>
</plist>

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.6-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.6-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": "^6.5.1",
"@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,17 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz",
"integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"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.2-beta",
"version": "1.1.6-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"
@@ -27,8 +27,8 @@
"auto-run:android": "./scripts/auto-run.sh --platform=android",
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync",
"build:native": "vite build && npx cap sync && npx capacitor-assets generate",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
"assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only",
@@ -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": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",

View File

@@ -21,7 +21,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 4,
workers: 3,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['list'],

View File

@@ -0,0 +1,60 @@
# Restore Local Capacitor Plugins
## Overview
The `restore-local-plugins.js` script ensures that local custom Capacitor plugins (`SafeArea` and `SharedImage`) are automatically restored to `android/app/src/main/assets/capacitor.plugins.json` after running `npx cap sync android`.
## Why This Is Needed
The `capacitor.plugins.json` file is auto-generated by Capacitor during `npx cap sync` and gets overwritten, removing any manually added local plugins. This script automatically restores them.
## Usage
### Automatic (Recommended)
The script is automatically run by:
- `./scripts/build-android.sh` (after `cap sync`)
- `npm run build:capacitor:sync`
- `npm run build:native`
### Manual
If you run `npx cap sync android` directly, you can restore plugins manually:
```bash
node scripts/restore-local-plugins.js
```
## What It Does
1. Reads `android/app/src/main/assets/capacitor.plugins.json`
2. Checks if local plugins (`SafeArea` and `SharedImage`) are present
3. Adds any missing local plugins
4. Preserves the existing JSON format
## Local Plugins
The following local plugins are automatically restored:
- **SafeArea**: `app.timesafari.safearea.SafeAreaPlugin`
- **SharedImage**: `app.timesafari.sharedimage.SharedImagePlugin`
## Adding New Local Plugins
To add a new local plugin, edit `scripts/restore-local-plugins.js` and add it to the `LOCAL_PLUGINS` array:
```javascript
const LOCAL_PLUGINS = [
// ... existing plugins ...
{
pkg: 'YourPluginName',
classpath: 'app.timesafari.yourpackage.YourPluginClass'
}
];
```
## Notes
- The script is idempotent - running it multiple times won't create duplicates
- The script preserves the existing JSON formatting (tabs, etc.)
- If the plugins file doesn't exist, the script will exit with an error (run `npx cap sync android` first)

View File

@@ -385,6 +385,7 @@ fi
if [ "$SYNC_ONLY" = true ]; then
log_info "Sync-only mode: syncing with Capacitor"
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
safe_execute "Restoring local plugins" "node scripts/restore-local-plugins.js" || exit 7
log_success "Sync completed successfully!"
exit 0
fi
@@ -436,7 +437,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 +460,26 @@ 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 8.5: Restore local plugins (capacitor.plugins.json gets overwritten by cap sync)
safe_execute "Restoring local plugins" "node scripts/restore-local-plugins.js" || exit 7
# 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 +492,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 +503,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

@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Restore Local Capacitor Plugins
*
* This script ensures that local custom plugins (SafeArea and SharedImage)
* are present in capacitor.plugins.json after `npx cap sync` runs.
*
* The capacitor.plugins.json file is auto-generated by Capacitor and gets
* overwritten during sync, so we need to restore our local plugins.
*
* Usage:
* node scripts/restore-local-plugins.js
*
* This should be run after `npx cap sync android` or `npx cap sync ios`
*/
const fs = require('fs');
const path = require('path');
const PLUGINS_FILE = path.join(__dirname, '../android/app/src/main/assets/capacitor.plugins.json');
// Local plugins that need to be added
const LOCAL_PLUGINS = [
{
pkg: 'SafeArea',
classpath: 'app.timesafari.safearea.SafeAreaPlugin'
},
{
pkg: 'SharedImage',
classpath: 'app.timesafari.sharedimage.SharedImagePlugin'
}
];
function restoreLocalPlugins() {
try {
// Read the current plugins file
if (!fs.existsSync(PLUGINS_FILE)) {
console.error(`❌ Plugins file not found: ${PLUGINS_FILE}`);
console.error(' Run "npx cap sync android" first to generate the file.');
process.exit(1);
}
const content = fs.readFileSync(PLUGINS_FILE, 'utf8');
let plugins = JSON.parse(content);
if (!Array.isArray(plugins)) {
console.error(`❌ Invalid plugins file format: expected array, got ${typeof plugins}`);
process.exit(1);
}
// Check which local plugins are missing
const existingPackages = new Set(plugins.map(p => p.pkg));
const missingPlugins = LOCAL_PLUGINS.filter(p => !existingPackages.has(p.pkg));
if (missingPlugins.length === 0) {
console.log('✅ All local plugins are already present in capacitor.plugins.json');
return;
}
// Add missing plugins
plugins.push(...missingPlugins);
// Write back to file with proper formatting (matching existing style)
const formatted = JSON.stringify(plugins, null, '\t');
fs.writeFileSync(PLUGINS_FILE, formatted + '\n', 'utf8');
console.log('✅ Restored local plugins to capacitor.plugins.json:');
missingPlugins.forEach(p => {
console.log(` - ${p.pkg} (${p.classpath})`);
});
} catch (error) {
console.error('❌ Error restoring local plugins:', error.message);
process.exit(1);
}
}
// Run the script
restoreLocalPlugins();

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

@@ -139,17 +139,65 @@ projects, and special entities with selection. * * @author Matthew Raymer */
</template>
<template v-else-if="entityType === 'projects'">
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
<!-- When showing projects without search: split into recently bookmarked and rest -->
<template v-if="!searchTerm.trim()">
<!-- Recently Bookmarked Section -->
<template v-if="recentBookmarkedProjects.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Bookmarked
</li>
<ProjectCard
v-for="project in recentBookmarkedProjects"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
<!-- Rest of Projects Section -->
<li
v-if="recentBookmarkedProjects.length > 0"
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
All Projects
</li>
<ProjectCard
v-for="project in remainingProjects"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
</template>
</ul>
</template>
@@ -175,6 +223,7 @@ import { TIMEOUTS } from "@/utils/notify";
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
const RECENT_BOOKMARKED_PROJECTS_COUNT = 10;
/**
* EntityGrid - Unified grid layout for displaying people or projects
@@ -223,6 +272,9 @@ export default class EntityGrid extends Vue {
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
// Starred projects state (for showing recently bookmarked projects)
starredPlanHandleIds: string[] = [];
/**
* Array of entities to display
*
@@ -378,7 +430,8 @@ export default class EntityGrid extends Vue {
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Get all contacts sorted alphabetically (when showing contacts and not searching)
* Includes contacts shown in "Recently Added" section as well
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
@@ -389,18 +442,69 @@ export default class EntityGrid extends Vue {
) {
return [];
}
// Skip the first few (recent contacts) and sort the rest alphabetically
// Sort all contacts alphabetically (including recent ones)
// 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);
const sorted = [...(this.entities as Contact[])].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
return sorted.slice(0, this.displayedCount);
}
/**
* Get the 3 most recently bookmarked projects (when showing projects and not searching)
* The starredPlanHandleIds array order represents bookmark order (newest at the end)
*/
get recentBookmarkedProjects(): PlanData[] {
if (
this.entityType !== "projects" ||
this.searchTerm.trim() ||
this.starredPlanHandleIds.length === 0
) {
return [];
}
const projects = this.entitiesToUse as PlanData[];
if (projects.length === 0) {
return [];
}
// Get the last 3 starred IDs (most recently bookmarked)
const recentStarredIds = this.starredPlanHandleIds.slice(
-RECENT_BOOKMARKED_PROJECTS_COUNT,
);
// Find projects matching those IDs, sorting with newest first
const recentProjects = recentStarredIds
.map((id) => projects.find((p) => p.handleId === id))
.filter((p): p is PlanData => p !== undefined)
.reverse();
return recentProjects;
}
/**
* Get all projects (when showing projects and not searching)
* Includes projects shown in "Recently Bookmarked" section as well
* Uses infinite scroll to control how many are displayed
*/
get remainingProjects(): PlanData[] {
if (this.entityType !== "projects" || this.searchTerm.trim()) {
return [];
}
const projects = this.entitiesToUse as PlanData[];
if (projects.length === 0) {
return [];
}
// Apply infinite scroll: show based on displayedCount
return projects.slice(0, this.displayedCount);
}
/**
@@ -455,6 +559,13 @@ export default class EntityGrid extends Vue {
return this.conflictChecker(did);
}
/**
* Check if a project handleId is conflicted
*/
isProjectConflicted(handleId: string): boolean {
return this.conflictChecker(handleId);
}
/**
* Handle person selection from PersonCard
*/
@@ -793,12 +904,11 @@ export default class EntityGrid extends Vue {
}
// People: check if more alphabetical contacts available
// Total available = recent + all alphabetical
// All contacts are shown alphabetically (recent ones appear in both sections)
if (!this.entities) {
return false;
}
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
return this.displayedCount < totalAvailable;
return this.displayedCount < this.entities.length;
}
/**
@@ -810,6 +920,9 @@ export default class EntityGrid extends Vue {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load starred project IDs for showing recently bookmarked projects
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Load projects on mount if entities prop not provided
if (!this.entities && this.apiServer) {
this.isLoadingProjects = true;
@@ -934,6 +1047,50 @@ export default class EntityGrid extends Vue {
return data;
}
/**
* Watch for changes in entityType to load projects when switching to projects
*/
@Watch("entityType")
async onEntityTypeChange(newType: "people" | "projects"): Promise<void> {
// Reset displayed count and clear search when switching types
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// When switching to projects, load them if not provided via entities prop
if (newType === "projects" && !this.entities) {
// Ensure apiServer is loaded
if (!this.apiServer) {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
}
// Load projects if we have an API server
if (this.apiServer && this.allProjects.length === 0) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error(
"Error loading projects when switching to projects:",
error,
);
} finally {
this.isLoadingProjects = false;
}
}
}
// Clear project state when switching away from projects
if (newType === "people") {
this.allProjects = [];
this.loadBeforeId = undefined;
}
}
/**
* Watch for changes in search term to reset displayed count and pagination
*/
@@ -959,7 +1116,7 @@ export default class EntityGrid extends Vue {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// For projects: if entities prop is provided, clear internal state
// For projects: clear internal state if entities prop is provided
if (this.entityType === "projects" && this.entities) {
this.allProjects = [];
this.loadBeforeId = undefined;

View File

@@ -8,9 +8,19 @@ notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
<label class="block font-bold mb-1">
{{ stepLabel }}
</label>
<!-- Toggle link for entity type selection -->
<div class="text-right mb-4">
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium"
@click="handleToggleEntityType"
>
{{ toggleLinkText }}
</button>
</div>
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
@@ -19,7 +29,6 @@ properties * * @author Matthew Raymer */
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:notify="notify"
:conflict-context="conflictContext"
@@ -90,10 +99,6 @@ export default class EntitySelectionStep extends Vue {
@Prop({ default: false })
showProjects!: boolean;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
@Prop({ required: false })
projects?: PlanData[];
@@ -160,15 +165,19 @@ export default class EntitySelectionStep extends Vue {
*/
get stepLabel(): string {
if (this.stepType === "recipient") {
return "Choose who received the gift:";
} else if (this.stepType === "giver") {
if (this.shouldShowProjects) {
return "Choose a project benefitted from:";
return "Choose recipient project";
} else {
return "Choose a person received from:";
return "Choose recipient person";
}
} else {
// this.stepType === "giver"
if (this.shouldShowProjects) {
return "Choose giving project";
} else {
return "Choose giving person";
}
}
return "Choose entity:";
}
/**
@@ -195,16 +204,6 @@ export default class EntitySelectionStep extends Vue {
return false;
}
/**
* Whether to show the "You" entity
*/
get shouldShowYouEntity(): boolean {
return (
this.stepType === "recipient" ||
(this.stepType === "giver" && this.isFromProjectView)
);
}
/**
* Whether the "You" entity is selectable
*/
@@ -212,6 +211,17 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Text for the toggle link
*/
get toggleLinkText(): string {
if (this.shouldShowProjects) {
return "... or choose a person instead →";
} else {
return "... or choose a project instead →";
}
}
/**
* Handle entity selection from EntityGrid
*/
@@ -222,6 +232,13 @@ export default class EntitySelectionStep extends Vue {
});
}
/**
* Handle toggle entity type button click
*/
handleToggleEntityType(): void {
this.emitToggleEntityType();
}
/**
* Handle cancel button click
*/
@@ -242,6 +259,11 @@ export default class EntitySelectionStep extends Vue {
emitCancel(): void {
// No return value needed
}
@Emit("toggle-entity-type")
emitToggleEntityType(): void {
// No return value needed
}
}
</script>

View File

@@ -1,16 +1,6 @@
/** * EntitySummaryButton.vue - Displays selected entity with edit capability *
* Extracted from GiftedDialog.vue to handle entity summary display in the gift *
details step with edit functionality. * * Features: * - Shows entity avatar
(person or project) * - Displays entity name and role label * - Handles editable
vs locked states * - Function props for parent control over edit behavior * -
Supports both person and project entity types * - Template streamlined with
computed CSS properties * * @author Matthew Raymer */
/* EntitySummaryButton.vue - Displays selected entity with edit capability */
<template>
<component
:is="editable ? 'button' : 'div'"
:class="containerClasses"
@click="handleClick"
>
<button :class="containerClasses" @click="handleClick">
<!-- Entity Icon/Avatar -->
<div>
<template v-if="entityType === 'project'">
@@ -47,14 +37,11 @@ computed CSS properties * * @author Matthew Raymer */
</h3>
</div>
<!-- Edit/Lock Icon -->
<p class="ms-auto text-sm pe-1" :class="iconClasses">
<font-awesome
:icon="editable ? 'pen' : 'lock'"
:title="editable ? 'Change' : 'Can\'t be changed'"
/>
<!-- Edit Icon -->
<p class="ms-auto text-sm pe-1 text-blue-500">
<font-awesome icon="pen" title="Change" />
</p>
</component>
</button>
</template>
<script lang="ts">
@@ -75,12 +62,12 @@ interface EntityData {
}
/**
* EntitySummaryButton - Displays selected entity with optional edit capability
* EntitySummaryButton - Displays selected entity with edit capability
*
* Features:
* - Shows entity avatar (person or project)
* - Displays entity name and role label
* - Handles editable vs locked states
* - Always editable - click to change entity
* - Function props for parent control over edit behavior
* - Supports both person and project entity types
* - Template streamlined with computed CSS properties
@@ -104,13 +91,9 @@ export default class EntitySummaryButton extends Vue {
@Prop({ required: true })
label!: string;
/** Whether the entity can be edited */
@Prop({ default: true })
editable!: boolean;
/**
* Function prop for handling edit requests
* Called when the button is clicked and editable, allowing parent to control edit behavior
* Called when the button is clicked, allowing parent to control edit behavior
*/
@Prop({ type: Function, default: () => {} })
onEditRequested!: (data: {
@@ -132,13 +115,6 @@ export default class EntitySummaryButton extends Vue {
return this.entity !== null && "profileImageUrl" in this.entity;
}
/**
* Computed CSS classes for the edit/lock icon
*/
get iconClasses(): string {
return this.editable ? "text-blue-500" : "text-slate-400";
}
/**
* Computed CSS classes for the entity name
*/
@@ -172,16 +148,13 @@ export default class EntitySummaryButton extends Vue {
}
/**
* Handle click event - only call function prop if editable
* Allows parent to control edit behavior and validation
* Handle click event - call function prop to allow parent to control edit behavior
*/
handleClick(): void {
if (this.editable) {
this.onEditRequested({
entityType: this.entityType,
entity: this.entity,
});
}
this.onEditRequested({
entityType: this.entityType,
entity: this.entity,
});
}
}
</script>
@@ -195,8 +168,4 @@ button {
button:hover {
background-color: #f1f5f9; /* hover:bg-slate-100 */
}
div {
cursor: default;
}
</style>

View File

@@ -16,7 +16,6 @@ control over updates and validation * * @author Matthew Raymer */
:entity="giver"
:entity-type="giverEntityType"
:label="giverLabel"
:editable="canEditGiver"
:on-edit-requested="handleEditGiver"
/>
@@ -25,7 +24,6 @@ control over updates and validation * * @author Matthew Raymer */
:entity="receiver"
:entity-type="recipientEntityType"
:label="recipientLabel"
:editable="canEditRecipient"
:on-edit-requested="handleEditRecipient"
/>
</div>
@@ -172,10 +170,6 @@ export default class GiftDetailsStep extends Vue {
@Prop({ default: "" })
prompt!: string;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Whether there's a conflict between giver and receiver */
@Prop({ default: false })
hasConflict!: boolean;
@@ -277,20 +271,6 @@ export default class GiftDetailsStep extends Vue {
: "Given to:";
}
/**
* Whether the giver can be edited
*/
get canEditGiver(): boolean {
return !(this.isFromProjectView && this.giverEntityType === "project");
}
/**
* Whether the recipient can be edited
*/
get canEditRecipient(): boolean {
return this.recipientEntityType === "person";
}
/**
* Computed CSS classes for submit button
*/

View File

@@ -3,18 +3,18 @@
<div
class="dialog"
data-testid="gifted-dialog"
:data-recipient-entity-type="recipientEntityType"
:data-recipient-entity-type="currentRecipientEntityType"
>
<!-- Step 1: Entity Selection -->
<EntitySelectionStep
v-show="firstStep"
:step-type="stepType"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:giver-entity-type="currentGiverEntityType"
:recipient-entity-type="currentRecipientEntityType"
:show-projects="
giverEntityType === 'project' || recipientEntityType === 'project'
currentGiverEntityType === 'project' ||
currentRecipientEntityType === 'project'
"
:is-from-project-view="isFromProjectView"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
@@ -29,6 +29,7 @@
:offer-id="offerId"
:notify="$notify"
@entity-selected="handleEntitySelected"
@toggle-entity-type="handleToggleEntityType"
@cancel="cancel"
/>
@@ -37,13 +38,12 @@
v-show="!firstStep"
:giver="giver"
:receiver="receiver"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:giver-entity-type="currentGiverEntityType"
:recipient-entity-type="currentRecipientEntityType"
:description="description"
:amount="parseFloat(amountInput) || 0"
:unit-code="unitCode"
:prompt="prompt"
:is-from-project-view="isFromProjectView"
:has-conflict="hasPersonConflict"
:offer-id="offerId"
:from-project-id="fromProjectId"
@@ -113,11 +113,10 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop({ default: "person" }) giverEntityType = "person" as
@Prop({ default: "person" }) initialGiverEntityType = "person" as
| "person"
| "project";
@Prop({ default: "person" }) recipientEntityType = "person" as
@Prop({ default: "person" }) initialRecipientEntityType = "person" as
| "person"
| "project";
@@ -131,6 +130,8 @@ export default class GiftedDialog extends Vue {
description = "";
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
currentGiverEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
currentRecipientEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
offerId = "";
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
@@ -142,20 +143,12 @@ export default class GiftedDialog extends Vue {
didInfo = didInfo;
// Computed property to help debug template logic
get shouldShowProjects() {
const result =
(this.stepType === "giver" && this.giverEntityType === "project") ||
(this.stepType === "recipient" && this.recipientEntityType === "project");
return result;
}
// Computed property to check if current selection would create a conflict
get hasPersonConflict() {
// Only check for conflicts when both entities are persons
if (
this.giverEntityType !== "person" ||
this.recipientEntityType !== "person"
this.currentGiverEntityType !== "person" ||
this.currentRecipientEntityType !== "person"
) {
return false;
}
@@ -172,22 +165,56 @@ export default class GiftedDialog extends Vue {
return false;
}
// Computed property to check if a contact would create a conflict when selected
wouldCreateConflict(contactDid: string) {
// Only check for conflicts when both entities are persons
// Computed property to check if current selection would create a project conflict
get hasProjectConflict() {
// Only check for conflicts when both entities are projects
if (
this.giverEntityType !== "person" ||
this.recipientEntityType !== "person"
this.currentGiverEntityType !== "project" ||
this.currentRecipientEntityType !== "project"
) {
return false;
}
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.did === contactDid;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.did === contactDid;
// Check if giver and recipient are the same project
if (
this.giver?.handleId &&
this.receiver?.handleId &&
this.giver.handleId === this.receiver.handleId
) {
return true;
}
return false;
}
// Computed property to check if a contact or project would create a conflict when selected
wouldCreateConflict(identifier: string) {
// Check for person conflicts when both entities are persons
if (
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "person"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.did === identifier;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.did === identifier;
}
}
// Check for project conflicts when both entities are projects
if (
this.currentGiverEntityType === "project" &&
this.currentRecipientEntityType === "project"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.handleId === identifier;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.handleId === identifier;
}
}
return false;
@@ -211,8 +238,9 @@ export default class GiftedDialog extends Vue {
this.amountInput = amountInput || "0";
this.unitCode = unitCode || "HUR";
this.callbackOnSuccess = callbackOnSuccess;
this.firstStep = !giver;
this.stepType = "giver";
// Initialize current entity types from initial prop values
this.currentGiverEntityType = this.initialGiverEntityType;
this.currentRecipientEntityType = this.initialRecipientEntityType;
try {
const settings = await this.$accountSettings();
@@ -223,6 +251,14 @@ export default class GiftedDialog extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
// Skip Step 1 if both giver and receiver are provided
const hasGiver = giver && (!!giver.did || !!giver.handleId);
const hasReceiver = receiver && (!!receiver.did || !!receiver.handleId);
this.firstStep = !hasGiver || !hasReceiver;
if (this.firstStep) {
this.stepType = giver ? "receiver" : "giver";
}
logger.debug("[GiftedDialog] Settings received:", {
activeDid: this.activeDid,
apiServer: this.apiServer,
@@ -278,6 +314,8 @@ export default class GiftedDialog extends Vue {
this.prompt = "";
this.unitCode = "HUR";
this.firstStep = true;
// Reset to initial prop values
this.currentGiverEntityType = this.initialGiverEntityType;
}
async confirm() {
@@ -315,6 +353,15 @@ export default class GiftedDialog extends Vue {
return;
}
// Check for project conflict
if (this.hasProjectConflict) {
this.safeNotify.error(
"You cannot select the same project as both giver and recipient.",
TIMEOUTS.STANDARD,
);
return;
}
this.close();
this.safeNotify.toast(
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE.message,
@@ -356,8 +403,8 @@ export default class GiftedDialog extends Vue {
let providerPlanHandleId: string | undefined;
if (
this.giverEntityType === "project" &&
this.recipientEntityType === "person"
this.currentGiverEntityType === "project" &&
this.currentRecipientEntityType === "person"
) {
// Project-to-person gift
fromDid = undefined; // No person giver
@@ -365,8 +412,8 @@ export default class GiftedDialog extends Vue {
fulfillsProjectHandleId = undefined; // No project recipient
providerPlanHandleId = this.giver?.handleId; // Project giver
} else if (
this.giverEntityType === "person" &&
this.recipientEntityType === "project"
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "project"
) {
// Person-to-project gift
fromDid = giverDid as string; // Person giver
@@ -483,10 +530,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;
}
@@ -523,17 +573,22 @@ export default class GiftedDialog extends Vue {
return {
amountInput: this.amountInput,
description: this.description,
giverDid: this.giverEntityType === "person" ? this.giver?.did : undefined,
giverDid:
this.currentGiverEntityType === "person" ? this.giver?.did : undefined,
giverName: this.giver?.name,
offerId: this.offerId,
fulfillsProjectId:
this.recipientEntityType === "project" ? this.toProjectId : undefined,
this.currentRecipientEntityType === "project"
? this.toProjectId
: undefined,
providerProjectId:
this.giverEntityType === "project"
this.currentGiverEntityType === "project"
? this.giver?.handleId
: this.fromProjectId,
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did : undefined,
this.currentRecipientEntityType === "person"
? this.receiver?.did
: undefined,
recipientName: this.receiver?.name,
unitCode: this.unitCode,
};
@@ -593,6 +648,7 @@ export default class GiftedDialog extends Vue {
entityType: string;
currentEntity: { did: string; name: string };
}) {
// Always allow editing - go back to Step 1 to select a new entity
this.goBackToStep1(data.entityType);
}
@@ -603,6 +659,24 @@ export default class GiftedDialog extends Vue {
this.confirm();
}
/**
* Handle toggle entity type request from EntitySelectionStep
*/
handleToggleEntityType() {
// Toggle the appropriate entity type based on current step
if (this.stepType === "giver") {
this.currentGiverEntityType =
this.currentGiverEntityType === "person" ? "project" : "person";
// Clear any selected giver when toggling
this.giver = undefined;
} else if (this.stepType === "recipient") {
this.currentRecipientEntityType =
this.currentRecipientEntityType === "person" ? "project" : "person";
// Clear any selected receiver when toggling
this.receiver = undefined;
}
}
/**
* Handle amount update from GiftDetailsStep
*/

View File

@@ -1,11 +1,8 @@
/** * ProjectCard.vue - Individual project display component * * Extracted from
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
GiftedDialog.vue to handle project entity display * with selection states,
conflict detection, and issuer information. * * @author Matthew Raymer */
<template>
<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"
>
<li :class="cardClasses" @click="handleClick">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="30"
@@ -14,8 +11,8 @@ issuer information. * * @author Matthew Raymer */
/>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<div class="text-xs text-slate-500 truncate">
@@ -33,6 +30,7 @@ import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
import { didInfo } from "../libs/endorserServer";
import { UNNAMED_PROJECT } from "@/constants/entities";
import { NotificationIface } from "../constants/app";
/**
* ProjectCard - Displays a project entity with selection capability
@@ -42,6 +40,8 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
* - Displays project name and issuer information
* - Handles click events for selection
* - Shows issuer name using didInfo utility
* - Selection states (selectable, conflicted, disabled)
* - Warning notifications for conflicted entities
*/
@Component({
components: {
@@ -65,6 +65,18 @@ export default class ProjectCard extends Vue {
@Prop({ required: true })
allContacts!: Contact[];
/** Whether this project would create a conflict if selected */
@Prop({ default: false })
conflicted!: boolean;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Context for conflict messages (e.g., "giver", "recipient") */
@Prop({ default: "other party" })
conflictContext!: string;
/**
* Get the unnamed project constant
*/
@@ -72,6 +84,51 @@ export default class ProjectCard extends Vue {
return UNNAMED_PROJECT;
}
/**
* 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.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the project name
*/
get nameClasses(): string {
const baseNameClasses = "text-sm font-semibold truncate";
if (this.conflicted) {
return `${baseNameClasses} text-slate-500`;
}
// Add italic styling for entities without set names
if (!this.project.name) {
return `${baseNameClasses} italic text-slate-500`;
}
return baseNameClasses;
}
/**
* Computed display name for the project
*/
get displayName(): string {
// If the project has a set name, use that name
if (this.project.name) {
return this.project.name;
}
// If the project does not have a set name
return this.unnamedProject;
}
/**
* Computed display name for the project issuer
*/
@@ -85,10 +142,23 @@ export default class ProjectCard extends Vue {
}
/**
* Handle card click - emit project selection
* Handle card click - emit if not conflicted, show warning if conflicted
*/
handleClick(): void {
this.emitProjectSelected(this.project);
if (!this.conflicted) {
this.emitProjectSelected(this.project);
} else if (this.notify) {
// Show warning notification for conflicted entity
this.notify(
{
group: "alert",
type: "warning",
title: "Cannot Select",
text: `You cannot select "${this.displayName}" because it is already selected as the ${this.conflictContext}.`,
},
3000,
);
}
}
// Emit methods using @Emit decorator

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

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

@@ -1686,7 +1686,10 @@ export async function register(
"Registration thrown error:",
errorMessage || JSON.stringify(err),
);
return { error: errorMessage || "Got a server error when registering." };
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

@@ -472,7 +472,7 @@ export function offerGiverDid(
if (giver && !serverUtil.isHiddenDid(giver)) {
return giver;
}
return giver;
return undefined;
}
/**

View File

@@ -30,11 +30,15 @@
import { initializeApp } from "./main.common";
import { App as CapacitorApp } from "@capacitor/app";
import { Capacitor } from "@capacitor/core";
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 { SharedImage } from "./plugins/SharedImagePlugin";
import "./utils/safeAreaInset";
logger.log("[Capacitor] 🚀 Starting initialization");
@@ -51,6 +55,55 @@ window.addEventListener("unhandledrejection", (event) => {
const deepLinkHandler = new DeepLinkHandler(router);
// Lock to prevent duplicate processing of shared images
let isProcessingSharedImage = false;
/**
* Stores shared image data in temp database
* Handles clearing old data, converting base64 to data URL, and storing
*
* @param base64 - Raw base64 string of the image
* @param fileName - Optional filename for logging
* @returns Promise<void>
*/
async function storeSharedImageInTempDB(
base64: string,
fileName?: string,
): Promise<void> {
const platformService = PlatformServiceFactory.getInstance();
// Clear old image from temp DB first to ensure we get the new one
try {
await platformService.dbExec("DELETE FROM temp WHERE id = ?", [
SHARED_PHOTO_BASE64_KEY,
]);
logger.debug("[Main] Cleared old shared image from temp DB");
} catch (clearError) {
logger.debug(
"[Main] No old image to clear (or error clearing):",
clearError,
);
}
// 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],
);
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
}
/**
* Handles deep link routing for the application
* Processes URLs in the format timesafari://<route>/<param>
@@ -67,11 +120,142 @@ const deepLinkHandler = new DeepLinkHandler(router);
*
* @throws {Error} If URL format is invalid
*/
/**
* Check for native shared image using SharedImage plugin
* Reads from native layer (App Group UserDefaults on iOS, SharedPreferences on Android)
* and stores in temp database before routing to shared-photo view
*/
async function checkAndStoreNativeSharedImage(): Promise<{
success: boolean;
fileName?: string;
}> {
// Prevent duplicate processing
if (isProcessingSharedImage) {
logger.debug(
"[Main] ⏸️ Shared image processing already in progress, skipping",
);
return { success: false };
}
isProcessingSharedImage = true;
try {
if (
!Capacitor.isNativePlatform() ||
(Capacitor.getPlatform() !== "ios" &&
Capacitor.getPlatform() !== "android")
) {
isProcessingSharedImage = false;
return { success: false };
}
// Use SharedImage plugin to get shared image data directly from native layer
// No file I/O or polling needed - direct native-to-JS communication
let result;
try {
result = await SharedImage.getSharedImage();
} catch (error) {
logger.error("[Main] Error calling SharedImage.getSharedImage():", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
isProcessingSharedImage = false;
return { success: false };
}
// Check if we have valid image data (base64 must be non-null and non-empty)
if (result && result.base64 && result.base64.trim().length > 0) {
const fileName = result.fileName || "shared-image.jpg";
// Store in temp database using extracted method
logger.info(
"[Main] Native shared image found (via plugin), storing in temp DB",
);
await storeSharedImageInTempDB(result.base64, fileName);
isProcessingSharedImage = false;
return { success: true, fileName };
}
// No shared image found
logger.debug("[Main] No shared image found via plugin");
isProcessingSharedImage = false;
return { success: false };
} catch (error) {
logger.error("[Main] Error checking for native shared image:", error);
isProcessingSharedImage = false;
return { success: false };
}
}
const handleDeepLink = async (data: { url: string }) => {
const { url } = data;
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
try {
// Handle empty path URLs from share extension (timesafari://)
// These are used to open the app, and we should check for shared images
const isEmptyPathUrl = url === "timesafari://" || url === "timesafari:///";
if (
isEmptyPathUrl &&
Capacitor.isNativePlatform() &&
Capacitor.getPlatform() === "ios"
) {
logger.debug(
"[Main] 📸 Empty path URL from share extension, checking for native shared image",
);
// Try to get shared image from App Group and store in temp database
// AppDelegate writes the file when the deep link is received, so we may need to retry
// The checkAndStoreNativeSharedImage function now uses polling internally, so we just call it once
try {
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info(
"[Main] ✅ Native shared image found, navigating to shared-photo",
);
// Wait for router to be ready
await router.isReady();
// Navigate directly to shared-photo route
// Use replace if already on shared-photo to force refresh, otherwise push
const fileName = imageResult.fileName || "shared-image.jpg";
const isAlreadyOnSharedPhoto =
router.currentRoute.value.path === "/shared-photo";
if (isAlreadyOnSharedPhoto) {
// Force refresh by replacing the route
await router.replace({
path: "/shared-photo",
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
});
} else {
await router.push({
path: "/shared-photo",
query: { fileName },
});
}
logger.info(
`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`,
);
return; // Exit early, don't process as deep link
} else {
logger.debug(
"[Main] No native shared image found, ignoring empty path URL",
);
return; // Exit early, don't process empty path as deep link
}
} catch (error) {
logger.error("[Main] Error processing native shared image:", error);
// If check fails, don't process as deep link (empty path would fail validation anyway)
return;
}
}
// Wait for router to be ready
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
await router.isReady();
@@ -159,10 +343,96 @@ const registerDeepLinkListener = async () => {
}
};
/**
* Check for shared image and navigate to shared-photo route if found
* This is called when app becomes active (from share extension or app launch)
*/
async function checkForSharedImageAndNavigate() {
if (
!Capacitor.isNativePlatform() ||
(Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android")
) {
return;
}
try {
logger.debug("[Main] 🔍 Checking for shared image on app activation");
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info(
"[Main] ✅ Shared image found, navigating to shared-photo route",
);
// Wait for router to be ready
await router.isReady();
// Navigate to shared-photo route with fileName if available
// Use replace if already on shared-photo to force refresh, otherwise push
const fileName = imageResult.fileName || "shared-image.jpg";
const isAlreadyOnSharedPhoto =
router.currentRoute.value.path === "/shared-photo";
if (isAlreadyOnSharedPhoto) {
// Force refresh by replacing the route
await router.replace({
path: "/shared-photo",
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
});
} else {
const route = imageResult.fileName
? `/shared-photo?fileName=${encodeURIComponent(imageResult.fileName)}`
: "/shared-photo";
await router.push(route);
}
logger.info(`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`);
} else {
logger.debug("[Main] No shared image found");
}
} catch (error) {
logger.error("[Main] ❌ Error checking for shared image:", error);
}
}
logger.log("[Capacitor] 🚀 Mounting app");
app.mount("#app");
logger.info(`[Main] ✅ App mounted successfully`);
// Check for shared image on initial load (in case app was launched from share sheet)
// On Android, share intents are processed in MainActivity.onCreate, so we need to check
// after a delay to ensure the native code has finished processing
if (
Capacitor.isNativePlatform() &&
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
) {
// Use multiple checks with increasing delays to handle timing issues
// Android share intent processing happens in onCreate, which may complete after JS loads
const checkDelays =
Capacitor.getPlatform() === "android"
? [500, 1500, 3000] // Android needs more time for share intent processing
: [1000]; // iOS is faster
checkDelays.forEach((delay) => {
setTimeout(async () => {
await checkForSharedImageAndNavigate();
}, delay);
});
}
// Listen for app state changes to detect when app becomes active
if (
Capacitor.isNativePlatform() &&
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
) {
CapacitorApp.addListener("appStateChange", async ({ isActive }) => {
if (isActive) {
logger.debug("[Main] 📱 App became active, checking for shared image");
await checkForSharedImageAndNavigate();
}
});
}
// Register deeplink listener after app is mounted
setTimeout(async () => {
try {

View File

@@ -0,0 +1,15 @@
/**
* SharedImage Capacitor Plugin
* Provides access to shared image data from native Share Extension/Intent
*/
import { registerPlugin } from "@capacitor/core";
import type { SharedImagePlugin } from "./definitions";
const SharedImage = registerPlugin<SharedImagePlugin>("SharedImage", {
web: () =>
import("./SharedImagePlugin.web").then((m) => new m.SharedImagePluginWeb()),
});
export * from "./definitions";
export { SharedImage };

View File

@@ -0,0 +1,21 @@
/**
* Web implementation of SharedImagePlugin
* Returns null/false for web platform (no native sharing support)
*/
import { WebPlugin } from "@capacitor/core";
import type { SharedImagePlugin, SharedImageResult } from "./definitions";
export class SharedImagePluginWeb
extends WebPlugin
implements SharedImagePlugin
{
async getSharedImage(): Promise<SharedImageResult | null> {
// Web platform doesn't support native sharing
return null;
}
async hasSharedImage(): Promise<{ hasImage: boolean }> {
return { hasImage: false };
}
}

View File

@@ -0,0 +1,23 @@
/**
* Type definitions for SharedImage plugin
*/
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
/**
* Get shared image data from native layer
* Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading
*/
getSharedImage(): Promise<SharedImageResult | null>;
/**
* Check if shared image exists without reading it
* Useful for quick checks before calling getSharedImage()
*/
hasSharedImage(): Promise<{ hasImage: boolean }>;
}

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

@@ -1367,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)
@@ -1377,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,
@@ -1387,6 +1390,8 @@ export const PlatformServiceMixin = {
safeContact.registered,
safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.contactMethods,
],
);

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

@@ -223,21 +223,30 @@
</div>
<div class="mt-8">
<button
v-if="libsUtil.canFulfillOffer(veriClaim, isRegistered)"
class="col-span-1 block w-fit 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="openFulfillGiftDialog()"
v-if="veriClaim.claimType === 'Offer'"
class="col-span-1 block w-fit text-center text-md px-1.5 py-2 rounded-md"
:class="
libsUtil.canFulfillOffer(veriClaim, isRegistered)
? 'bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white'
: 'bg-gray-300 text-gray-500 cursor-pointer opacity-50'
"
@click="handleAffirmDeliveryClick()"
>
Affirm Delivery
<font-awesome
icon="hand-holding-heart"
class="ml-2 text-white cursor-pointer"
:class="
libsUtil.canFulfillOffer(veriClaim, isRegistered)
? 'ml-2 text-white cursor-pointer'
: 'ml-2 text-gray-500 cursor-pointer'
"
/>
</button>
</div>
<GiftedDialog
ref="customGiveDialog"
:giver-entity-type="'person'"
:recipient-entity-type="projectInfo ? 'project' : 'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="projectInfo ? 'project' : 'person'"
:to-project-id="
detailsForGive?.fulfillsPlanHandleId ||
detailsForOffer?.fulfillsPlanHandleId ||
@@ -1103,11 +1112,29 @@ export default class ClaimView extends Vue {
});
}
handleAffirmDeliveryClick() {
if (!this.isRegistered) {
this.notify.error("You must be registered to affirm delivery.");
return;
}
if (!libsUtil.canFulfillOffer(this.veriClaim, this.isRegistered)) {
this.notify.error(
"You cannot see all the information in this offer so you cannot affirm delivery.",
);
return;
}
this.openFulfillGiftDialog();
}
openFulfillGiftDialog() {
const giverDid = libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferClaim>,
);
// Look up the giver contact to get their name
const giverContact = serverUtil.contactForDid(giverDid, this.allContacts);
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferClaim>,
),
did: giverDid,
name: giverContact?.name || giverDid, // Use contact name if available, otherwise DID
};
// Determine recipient based on whether it's a project or person

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,9 +320,10 @@ 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,
});

View File

@@ -105,11 +105,10 @@
<GiftedDialog
ref="giftedDialog"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:initial-giver-entity-type="giverEntityType"
:initial-recipient-entity-type="recipientEntityType"
:from-project-id="fromProjectId"
:to-project-id="toProjectId"
:is-from-project-view="isFromProjectView"
:hide-show-all="true"
/>
</section>
@@ -165,7 +164,6 @@ export default class ContactGiftingView extends Vue {
fromProjectId = "";
toProjectId = "";
showProjects = false;
isFromProjectView = false;
offerId = "";
async created() {
@@ -217,8 +215,6 @@ export default class ContactGiftingView extends Vue {
this.toProjectId = (this.$route.query["toProjectId"] as string) || "";
this.showProjects =
(this.$route.query["showProjects"] as string) === "true";
this.isFromProjectView =
(this.$route.query["isFromProjectView"] as string) === "true";
this.offerId = (this.$route.query["offerId"] as string) || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -120,8 +120,8 @@
<GiftedDialog
ref="customGivenDialog"
:giver-entity-type="'person'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'person'"
/>
<OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
@@ -1088,7 +1088,7 @@ export default class ContactsView extends Vue {
{
group: "modal",
type: "confirm",
title: "Delete",
title: "Confirm First?",
text: message,
onNo: async () => {
this.showGiftedDialog(giverDid, recipientDid);

View File

@@ -20,12 +20,12 @@
</button>
<!-- Help button -->
<button
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="goToHelp()"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</button>
</router-link>
</div>
<!-- Identity Details -->
@@ -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

View File

@@ -465,6 +465,8 @@ export default class DiscoverView extends Vue {
async mounted() {
this.searchTerms = this.$route.query["searchText"]?.toString() || "";
const hideOnboarding =
this.$route.query["hideOnboarding"]?.toString() === "true";
const searchPeople = !!this.$route.query["searchPeople"];
@@ -483,7 +485,7 @@ export default class DiscoverView extends Vue {
this.allMyDids = await retrieveAccountDids();
if (!settings.finishedOnboarding) {
if (!settings.finishedOnboarding && !hideOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Discover,
);

View File

@@ -111,7 +111,34 @@ Raymer * @version 1.0.0 */
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<h2 class="font-bold">Record something given by:</h2>
<!-- Thank button - always visible and unchanged -->
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 px-4 py-3 rounded-full"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
<span>Thank</span>
</button>
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
<transition
enter-active-class="transition-all duration-1000 ease-out"
leave-active-class="transition-all duration-1000 ease-in"
enter-from-class="scale-0"
enter-to-class="scale-100"
leave-from-class="scale-100"
leave-to-class="scale-0"
>
<button
v-if="isScrolled"
type="button"
class="fixed bottom-10 p-4 w-14 h-14 z-50 text-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-full flex items-center justify-center"
:style="getButtonPosition()"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
</button>
</transition>
<button
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openGiftedPrompts()"
@@ -122,25 +149,6 @@ Raymer * @version 1.0.0 */
/>
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openPersonDialog()"
>
<font-awesome icon="user" />
Person
</button>
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openProjectDialog()"
>
<font-awesome icon="folder-open" />
Project
</button>
</div>
</div>
</div>
</div>
@@ -148,8 +156,8 @@ Raymer * @version 1.0.0 */
<GiftedDialog
ref="giftedDialog"
:giver-entity-type="showProjectsDialog ? 'project' : 'person'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'person'"
/>
<GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" />
@@ -446,7 +454,8 @@ export default class HomeView extends Vue {
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
selectedImage = "";
isImageViewerOpen = false;
showProjectsDialog = false;
isScrolled = false;
scrollHandler?: () => void;
/**
* CRITICAL VUE REACTIVITY BUG WORKAROUND
@@ -547,11 +556,44 @@ export default class HomeView extends Vue {
this.numNewOffersToUser + this.numNewOffersToUserProjects > 0,
},
});
// Add scroll listener for button collapse
// Note: Scrolling happens on #app element, not window (see tailwind.css)
const appElement = document.getElementById("app");
const scrollElement = appElement || window;
this.scrollHandler = () => {
const scrollTop = appElement
? appElement.scrollTop
: window.pageYOffset || document.documentElement.scrollTop || 0;
const shouldBeScrolled = scrollTop > 100;
if (this.isScrolled !== shouldBeScrolled) {
this.isScrolled = shouldBeScrolled;
}
};
// Set initial state
this.scrollHandler();
// Listen on scroll element (prefer #app, fallback to window)
scrollElement.addEventListener("scroll", this.scrollHandler, {
passive: true,
});
} catch (err: unknown) {
this.handleError(err);
}
}
/**
* Cleanup scroll listener on component unmount
*/
beforeUnmount() {
if (this.scrollHandler) {
const appElement = document.getElementById("app");
const scrollElement = appElement || window;
scrollElement.removeEventListener("scroll", this.scrollHandler);
}
}
/**
* Watch for changes in the current activeDid
* Reload settings when user switches identities
@@ -898,7 +940,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
@@ -1805,17 +1853,19 @@ export default class HomeView extends Vue {
* - this.activeDid
*
* @param giver Optional contact info for giver
* @param description Optional gift description
* @param prompt Optional gift prompt
*/
openDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
// Determine the giver entity based on DID logic
const giverEntity = this.createGiverEntity(giver);
// In HomeView, "You" is the default recipient but it's not locked
// User can still change it in Step 1 if they want
(this.$refs.giftedDialog as GiftedDialog).open(
giverEntity,
{
did: this.activeDid,
name: "You", // In HomeView, we always use "You" as the giver
name: "You",
} as GiverReceiverInputInfo,
undefined,
prompt,
@@ -1913,15 +1963,9 @@ export default class HomeView extends Vue {
}
openPersonDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
this.showProjectsDialog = false;
this.openDialog(giver, prompt);
}
openProjectDialog() {
this.showProjectsDialog = true;
(this.$refs.giftedDialog as GiftedDialog).open();
}
/**
* Computed property for registration status
*
@@ -1931,5 +1975,39 @@ export default class HomeView extends Vue {
get isUserRegistered() {
return this.isRegistered;
}
/**
* Calculates the horizontal position for the button to align with house button center
*/
getButtonPosition() {
const quickNav = document.getElementById("QuickNav");
if (!quickNav) {
return { left: "1.5rem" }; // Fallback to left-6
}
const navList = quickNav.querySelector("ul");
if (!navList) {
return { left: "1.5rem" };
}
// Get the first nav item (house button)
const firstItem = navList.querySelector("li:first-child");
if (!firstItem) {
return { left: "1.5rem" };
}
const itemRect = firstItem.getBoundingClientRect();
const buttonWidth = 56; // w-14 = 3.5rem = 56px
// Calculate center of house button
const houseButtonCenter = itemRect.left + itemRect.width / 2;
// Position button so its center aligns with house button center
const buttonLeft = houseButtonCenter - buttonWidth / 2;
return {
left: `${buttonLeft}px`,
};
}
}
</script>

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

@@ -331,7 +331,7 @@ export default class OfferDetailsView extends Vue {
get recipientAssignmentLabel() {
return this.recipientDid
? `This is offered to ${this.recipientName}`
: "No recipient was chosen.";
: "No named individual recipient was chosen.";
}
/**

View File

@@ -238,10 +238,9 @@
<GiftedDialog
ref="giveDialogToThis"
:giver-entity-type="'person'"
:recipient-entity-type="'project'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'project'"
:to-project-id="projectId"
:is-from-project-view="true"
/>
<!-- Offers & Gifts to & from this -->
@@ -521,10 +520,9 @@
</div>
<GiftedDialog
ref="giveDialogFromThis"
:giver-entity-type="'project'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'project'"
:initial-recipient-entity-type="'person'"
:from-project-id="projectId"
:is-from-project-view="true"
/>
<h3 class="text-lg font-bold leading-tight mb-3">
@@ -1299,9 +1297,15 @@ export default class ProjectViewView extends Vue {
claim: offer.fullClaim,
issuer: offer.offeredByDid,
};
const giverDid = libsUtil.offerGiverDid(offerClaimCred);
// Look up the giver contact to get their name
const giverContact = serverUtil.contactForDid(giverDid, this.allContacts);
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(offerClaimCred),
did: giverDid,
name: giverContact?.name || giverDid, // Use contact name if available, otherwise DID
};
(this.$refs.giveDialogToThis as GiftedDialog).open(
giver,
{

View File

@@ -120,7 +120,7 @@
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { Component, Vue, Watch } from "vue-facing-decorator";
import {
RouteLocationNormalizedLoaded,
RouteLocationRaw,
@@ -191,7 +191,30 @@ export default class SharedPhotoView extends Vue {
*/
async mounted() {
this.notify = createNotifyHelpers(this.$notify);
await this.loadSharedImage();
}
/**
* Watches for route query changes to reload image when navigating
* to the same route with different query parameters (e.g., new fileName or _refresh)
* This handles both new navigations and refreshes when already on the route
*/
@Watch("$route.query", { deep: true })
async onRouteQueryChange() {
// Reload image when any query parameter changes (fileName or _refresh)
// This ensures the image refreshes when a new image is shared while already on this view
await this.loadSharedImage();
}
/**
* Loads the shared image from temporary storage
*
* Retrieves the shared image data from the temp database, converts it to a blob,
* and updates component state. Cleans up temporary storage after successful loading.
*
* @async
*/
private async loadSharedImage() {
try {
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -209,6 +232,9 @@ export default class SharedPhotoView extends Vue {
this.imageFileName = this.$route.query["fileName"] as string;
} else {
logger.error("No appropriate image found in temp storage.", temp);
// Clear image state if no temp data found
this.imageBlob = undefined;
this.imageFileName = undefined;
}
} catch (err: unknown) {
logger.error("Got an error loading an identifier:", err);
@@ -216,6 +242,9 @@ export default class SharedPhotoView extends Vue {
NOTIFY_SHARED_PHOTO_LOAD_ERROR.message,
TIMEOUTS.STANDARD,
);
// Clear image state on error
this.imageBlob = undefined;
this.imageFileName = undefined;
}
}

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,109 @@
</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
// (which we know because there is no neighbor in-between them)
"
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 +262,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 +290,8 @@ export default class UserProfileView extends Vue {
*/
async mounted() {
await this.initializeSettings();
await this.loadContacts();
await this.loadProfile();
await this.loadNeighbors();
}
/**
@@ -199,12 +306,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 +351,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 +509,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>

View File

@@ -282,9 +282,9 @@ test('Check User 0 can register a random person', async ({ page }) => {
} catch (error) {
console.log('Could not force close dialog, continuing...');
}
// Wait for Person button to be ready - simplified approach
await page.waitForSelector('button:has-text("Person")', { timeout: 10000 });
await page.getByRole('button', { name: 'Person' }).click();
// Wait for Thank button to be ready - simplified approach
await page.waitForSelector('button:has-text("Thank")', { timeout: 10000 });
await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill('Gave me access!');
await page.getByRole('button', { name: 'Sign & Send' }).click();

View File

@@ -107,7 +107,7 @@ test('Record something given', async ({ page }) => {
return !document.querySelector('.dialog-overlay');
}, { timeout: 5000 });
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());

View File

@@ -116,7 +116,7 @@ test('Record 9 new gifts', async ({ page }) => {
if (i === 0) {
await page.getByTestId('closeOnboardingAndFinish').click();
}
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill(finalTitles[i]);
await page.getByRole('spinbutton').fill(finalNumbers[i].toString());

View File

@@ -1,7 +1,8 @@
import { test, expect, Page } from '@playwright/test';
import { importUser } from './testUtils';
async function testProjectGive(page: Page, selector: string) {
async function testProjectGive(page: Page, isToProject: boolean) {
const selector = isToProject ? 'gives-to' : 'gives-from';
// Generate a random string of a few characters
const randomString = Math.random().toString(36).substring(2, 6);
@@ -42,9 +43,9 @@ async function testProjectGive(page: Page, selector: string) {
}
test('Record a give to a project', async ({ page }) => {
await testProjectGive(page, 'gives-to');
await testProjectGive(page, true);
});
test('Record a give from a project', async ({ page }) => {
await testProjectGive(page, 'gives-from');
await testProjectGive(page, false);
});

View File

@@ -117,7 +117,7 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Confirm that home shows contact in "Record Something…"
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('button', { name: 'Thank' }).click();
await expect(page.locator('#sectionGiftedGiver').getByRole('listitem').filter({ hasText: contactName })).toBeVisible();
// Record something given by new contact