Compare commits
33 Commits
project-re
...
web-share-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae49c0e907 | ||
|
|
1b4ab7a500 | ||
| 6ec2002cb0 | |||
|
|
36eb9a16b0 | ||
| 7d295dd062 | |||
| 5f1b4dcc21 | |||
| 11f122552d | |||
| c84a3b6705 | |||
|
|
203cf6b078 | ||
|
|
9b84b28a78 | ||
| e64902321f | |||
| 7abce8f95c | |||
| 88dce4d100 | |||
|
|
c4eb6f2d1d | ||
| 06fdaff879 | |||
| 8024a3d02a | |||
|
|
223031866b | ||
|
|
cb75b25529 | ||
| 83b470e28a | |||
|
|
acf104eaa7 | ||
|
|
e793d7a9e2 | ||
|
|
3ecae0be0f | ||
|
|
d37e53b1a9 | ||
|
|
2f89c7e13b | ||
|
|
6bf4055c2f | ||
|
|
bf7ee630d0 | ||
| 1739567b18 | |||
|
|
a5a9af5ddc | ||
| 5050156beb | |||
| d265a9f78c | |||
| f848de15f1 | |||
| ebaf2dedf0 | |||
|
|
749204f96b |
61
BUILDING.md
61
BUILDING.md
@@ -1151,28 +1151,28 @@ If you need to build manually or want to understand the individual steps:
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx:
|
||||
|
||||
```bash
|
||||
gem_path=$(which gem)
|
||||
shortened_path="${gem_path:h:h}"
|
||||
export GEM_HOME=$shortened_path
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
```bash
|
||||
gem_path=$(which gem)
|
||||
shortened_path="${gem_path:h:h}"
|
||||
export GEM_HOME=$shortened_path
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
|
||||
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
|
||||
|
||||
```bash
|
||||
cd ios/App && xcrun agvtool new-version 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 48 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.3;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
|
||||
Here's prod. Also available: test, dev
|
||||
Here's prod. Also available: test, dev
|
||||
|
||||
```bash
|
||||
npm run build:ios:prod
|
||||
```
|
||||
```bash
|
||||
npm run build:ios:prod
|
||||
```
|
||||
|
||||
3.1. Use Xcode to build and run on simulator or device.
|
||||
|
||||
@@ -1197,7 +1197,8 @@ If you need to build manually or want to understand the individual steps:
|
||||
- It can take 15 minutes for the build to show up in the list of builds.
|
||||
- You'll probably have to "Manage" something about encryption, disallowed in France.
|
||||
- Then "Save" and "Add to Review" and "Resubmit to App Review".
|
||||
- Eventually it'll be "Ready for Distribution" which means
|
||||
- Eventually it'll be "Ready for Distribution" which means it's live
|
||||
- When finished, bump package.json version
|
||||
|
||||
### Android Build
|
||||
|
||||
@@ -1303,8 +1304,8 @@ The recommended way to build for Android is using the automated build script:
|
||||
# Standard build and open Android Studio
|
||||
./scripts/build-android.sh
|
||||
|
||||
# Build with specific version numbers
|
||||
./scripts/build-android.sh --version 1.0.3 --build-number 35
|
||||
# Build with specific version numbers -- doesn't change source files
|
||||
#./scripts/build-android.sh --version 1.1.3 --build-number 48
|
||||
|
||||
# Build without opening Android Studio (for CI/CD)
|
||||
./scripts/build-android.sh --no-studio
|
||||
@@ -1315,26 +1316,26 @@ The recommended way to build for Android is using the automated build script:
|
||||
|
||||
#### Android Manual Build Process
|
||||
|
||||
##### 1. Bump the version in package.json, then here: android/app/build.gradle
|
||||
##### 1. Bump the version in package.json, then update these versions & run:
|
||||
|
||||
```bash
|
||||
perl -p -i -e 's/versionCode .*/versionCode 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 48/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.1.3"/g' android/app/build.gradle
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
|
||||
Here's prod. Also available: test, dev
|
||||
|
||||
```bash
|
||||
npm run build:android:prod
|
||||
```
|
||||
```bash
|
||||
npm run build:android:prod
|
||||
```
|
||||
|
||||
##### 3. Open the project in Android Studio
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
##### 4. Use Android Studio to build and run on emulator or device
|
||||
|
||||
@@ -1379,6 +1380,8 @@ At play.google.com/console:
|
||||
- Note that if you add testers, you have to go to "Publishing Overview" and send
|
||||
those changes or your (closed) testers won't see it.
|
||||
|
||||
- When finished, bump package.json version
|
||||
|
||||
### Capacitor Operations
|
||||
|
||||
```bash
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [1.1.3] - 2025.11.19
|
||||
### Changed
|
||||
- Project selection in dialogs now reaches out to server when filtering
|
||||
- Project selection during onboarding meeting is a search (not an input box)
|
||||
- Improve the switching of agent when agent edits a project
|
||||
### Fixed
|
||||
- Reassignment of "you" as recipient when changing giver project
|
||||
- Bad counts for project-change notification on front page
|
||||
|
||||
|
||||
## [1.1.2] - 2025.11.06
|
||||
### Fixed
|
||||
- Bad page when user follows prompt to backup seed
|
||||
|
||||
|
||||
## [1.1.1] - 2025.11.03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -31,8 +31,8 @@ android {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 46
|
||||
versionName "1.1.1"
|
||||
versionCode 48
|
||||
versionName "1.1.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
139
doc/ios-share-extension-git-commit-guide.md
Normal file
139
doc/ios-share-extension-git-commit-guide.md
Normal 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.
|
||||
|
||||
140
doc/ios-share-extension-setup.md
Normal file
140
doc/ios-share-extension-setup.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# iOS Share Extension Setup Instructions
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Purpose:** Step-by-step instructions for setting up the iOS Share Extension in Xcode
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Xcode installed
|
||||
- iOS project already set up with Capacitor
|
||||
- Access to Apple Developer account (for App Groups)
|
||||
|
||||
## Step 1: Create Share Extension Target
|
||||
|
||||
1. Open `ios/App/App.xcodeproj` in Xcode
|
||||
2. In the Project Navigator, select the **App** project (top-level item)
|
||||
3. Click the **+** button at the bottom of the Targets list
|
||||
4. Select **iOS** → **Share Extension**
|
||||
5. Click **Next**
|
||||
6. Configure:
|
||||
- **Product Name:** `TimeSafariShareExtension`
|
||||
- **Bundle Identifier:** `app.timesafari.shareextension` (must match main app's bundle ID with `.shareextension` suffix)
|
||||
- **Language:** Swift
|
||||
7. Click **Finish**
|
||||
|
||||
## Step 2: Configure Share Extension Files
|
||||
|
||||
The following files have been created in `ios/App/TimeSafariShareExtension/`:
|
||||
|
||||
- `ShareViewController.swift` - Main extension logic
|
||||
- `Info.plist` - Extension configuration
|
||||
|
||||
**Verify these files exist and are added to the Share Extension target.**
|
||||
|
||||
## Step 3: Configure App Groups
|
||||
|
||||
App Groups allow the Share Extension and main app to share data.
|
||||
|
||||
### For Main App Target:
|
||||
|
||||
1. Select the **App** target in Xcode
|
||||
2. Go to **Signing & Capabilities** tab
|
||||
3. Click **+ Capability**
|
||||
4. Select **App Groups**
|
||||
5. Click **+** to add a new group
|
||||
6. Enter: `group.app.timesafari`
|
||||
7. Ensure it's checked/enabled
|
||||
|
||||
### For Share Extension Target:
|
||||
|
||||
1. Select the **TimeSafariShareExtension** target
|
||||
2. Go to **Signing & Capabilities** tab
|
||||
3. Click **+ Capability**
|
||||
4. Select **App Groups**
|
||||
5. Click **+** to add a new group
|
||||
6. Enter: `group.app.timesafari` (same as main app)
|
||||
7. Ensure it's checked/enabled
|
||||
|
||||
**Important:** Both targets must use the **exact same** App Group identifier.
|
||||
|
||||
## Step 4: Configure Share Extension Info.plist
|
||||
|
||||
The `Info.plist` file should already be configured, but verify:
|
||||
|
||||
1. Select `TimeSafariShareExtension/Info.plist` in Xcode
|
||||
2. Ensure it contains:
|
||||
- `NSExtensionPointIdentifier` = `com.apple.share-services`
|
||||
- `NSExtensionPrincipalClass` = `$(PRODUCT_MODULE_NAME).ShareViewController`
|
||||
- `NSExtensionActivationSupportsImageWithMaxCount` = `1`
|
||||
|
||||
## Step 5: Add ShareImageBridge to Main App
|
||||
|
||||
1. The file `ios/App/App/ShareImageBridge.swift` has been created
|
||||
2. Ensure it's added to the **App** target (not the Share Extension target)
|
||||
3. In Xcode, select the file and check the **Target Membership** in the File Inspector
|
||||
|
||||
## Step 6: Build and Test
|
||||
|
||||
1. Select the **App** scheme (not the Share Extension scheme)
|
||||
2. Build and run on a device or simulator
|
||||
3. Open Photos app
|
||||
4. Select an image
|
||||
5. Tap **Share** button
|
||||
6. Look for **TimeSafari Share** in the share sheet
|
||||
7. Select it
|
||||
8. The app should open and navigate to the shared photo view
|
||||
|
||||
## Step 7: Troubleshooting
|
||||
|
||||
### Share Extension doesn't appear in share sheet
|
||||
|
||||
- Verify the Share Extension target builds successfully
|
||||
- Check that `Info.plist` is correctly configured
|
||||
- Ensure the extension's bundle identifier follows the pattern: `{main-app-bundle-id}.shareextension`
|
||||
- Clean build folder (Product → Clean Build Folder)
|
||||
|
||||
### App Group access fails
|
||||
|
||||
- Verify both targets have the same App Group identifier
|
||||
- Check that App Groups capability is enabled for both targets
|
||||
- Ensure you're signed in with a valid Apple Developer account
|
||||
- For development, you may need to enable App Groups in your Apple Developer account
|
||||
|
||||
### Shared image not appearing
|
||||
|
||||
- Check Xcode console for errors
|
||||
- Verify `ShareViewController.swift` is correctly implemented
|
||||
- Ensure the deep link `timesafari://shared-photo` is being handled
|
||||
- Check that the native bridge method is being called
|
||||
|
||||
### Build errors
|
||||
|
||||
- Ensure Swift version matches between targets
|
||||
- Check that all required frameworks are linked
|
||||
- Verify deployment targets match between main app and extension
|
||||
|
||||
## Step 8: Native Bridge Implementation (TODO)
|
||||
|
||||
Currently, the JavaScript code needs a way to call the native `getSharedImageData()` method. This requires one of:
|
||||
|
||||
1. **Option A:** Create a minimal Capacitor plugin
|
||||
2. **Option B:** Use Capacitor's existing bridge mechanisms
|
||||
3. **Option C:** Expose the method via a custom URL scheme parameter
|
||||
|
||||
The current implementation in `main.capacitor.ts` has a placeholder that needs to be completed.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After the Share Extension is set up and working:
|
||||
|
||||
1. Complete the native bridge implementation to read from App Group
|
||||
2. Test end-to-end flow: Share image → Extension stores → App reads → Displays
|
||||
3. Implement Android version
|
||||
4. Add error handling and edge cases
|
||||
|
||||
## References
|
||||
|
||||
- [Apple Share Extensions Documentation](https://developer.apple.com/documentation/social)
|
||||
- [App Groups Documentation](https://developer.apple.com/documentation/xcode/configuring-app-groups)
|
||||
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)
|
||||
|
||||
93
doc/ios-share-implementation-status.md
Normal file
93
doc/ios-share-implementation-status.md
Normal 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
|
||||
|
||||
507
doc/native-share-target-implementation.md
Normal file
507
doc/native-share-target-implementation.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# Native Share Target Implementation Guide
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Purpose:** Enable TimeSafari native iOS and Android apps to receive shared images from other apps
|
||||
|
||||
## Current State
|
||||
|
||||
The app currently supports **PWA/web share target** functionality:
|
||||
- Service worker intercepts POST to `/share-target`
|
||||
- Images stored in temp database as base64
|
||||
- `SharedPhotoView.vue` processes and displays shared images
|
||||
|
||||
**This does NOT work for native iOS/Android builds** because:
|
||||
- Service workers don't run in native app contexts
|
||||
- Native platforms use different sharing mechanisms (Share Extensions on iOS, Intent Filters on Android)
|
||||
|
||||
## Required Changes
|
||||
|
||||
### 1. iOS Implementation
|
||||
|
||||
#### 1.1 Create Share Extension Target
|
||||
|
||||
1. Open `ios/App/App.xcodeproj` in Xcode
|
||||
2. File → New → Target
|
||||
3. Select "Share Extension" template
|
||||
4. Name it "TimeSafariShareExtension"
|
||||
5. Bundle Identifier: `app.timesafari.shareextension`
|
||||
6. Language: Swift
|
||||
|
||||
#### 1.2 Configure Share Extension Info.plist
|
||||
|
||||
Add to `ios/App/TimeSafariShareExtension/Info.plist`:
|
||||
|
||||
```xml
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
```
|
||||
|
||||
#### 1.3 Implement ShareViewController
|
||||
|
||||
Create `ios/App/TimeSafariShareExtension/ShareViewController.swift`:
|
||||
|
||||
```swift
|
||||
import UIKit
|
||||
import Social
|
||||
import MobileCoreServices
|
||||
import Capacitor
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
self.title = "Share to TimeSafari"
|
||||
}
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let itemProvider = extensionItem.attachments?.first else {
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle image sharing
|
||||
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
|
||||
itemProvider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let url = item as? URL {
|
||||
// Handle file URL
|
||||
self.handleSharedImage(url: url)
|
||||
} else if let image = item as? UIImage {
|
||||
// Handle UIImage directly
|
||||
self.handleSharedImage(image: image)
|
||||
} else if let data = item as? Data {
|
||||
// Handle image data
|
||||
self.handleSharedImage(data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSharedImage(url: URL? = nil, image: UIImage? = nil, data: Data? = nil) {
|
||||
var imageData: Data?
|
||||
var fileName: String?
|
||||
|
||||
if let url = url {
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
} else if let image = image {
|
||||
imageData = image.jpegData(compressionQuality: 0.8)
|
||||
fileName = "shared-image.jpg"
|
||||
} else if let data = data {
|
||||
imageData = data
|
||||
fileName = "shared-image.jpg"
|
||||
}
|
||||
|
||||
guard let imageData = imageData else {
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
let base64String = imageData.base64EncodedString()
|
||||
|
||||
// Store in shared UserDefaults (accessible by main app)
|
||||
let userDefaults = UserDefaults(suiteName: "group.app.timesafari")
|
||||
userDefaults?.set(base64String, forKey: "sharedPhotoBase64")
|
||||
userDefaults?.set(fileName ?? "shared-image.jpg", forKey: "sharedPhotoFileName")
|
||||
userDefaults?.synchronize()
|
||||
|
||||
// Open main app with deep link
|
||||
let url = URL(string: "timesafari://shared-photo?fileName=\(fileName?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "shared-image.jpg")")!
|
||||
var responder = self as UIResponder?
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
|
||||
// Close share extension
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
|
||||
override func configurationItems() -> [Any]! {
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 Configure App Groups
|
||||
|
||||
1. In Xcode, select main app target → Signing & Capabilities
|
||||
2. Add "App Groups" capability
|
||||
3. Create group: `group.app.timesafari`
|
||||
4. Repeat for Share Extension target with same group name
|
||||
|
||||
#### 1.5 Update Main App to Read from App Group
|
||||
|
||||
The main app needs to check for shared images on launch. This should be added to `AppDelegate.swift` or handled in JavaScript.
|
||||
|
||||
### 2. Android Implementation
|
||||
|
||||
#### 2.1 Update AndroidManifest.xml
|
||||
|
||||
Add intent filter to `MainActivity` in `android/app/src/main/AndroidManifest.xml`:
|
||||
|
||||
```xml
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
... existing attributes ...>
|
||||
|
||||
... existing intent filters ...
|
||||
|
||||
<!-- Share Target Intent Filter -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Multiple images support (optional) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
```
|
||||
|
||||
#### 2.2 Handle Intent in MainActivity
|
||||
|
||||
Update `android/app/src/main/java/app/timesafari/MainActivity.java`:
|
||||
|
||||
```java
|
||||
package app.timesafari;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.getcapacitor.Plugin;
|
||||
import java.io.InputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
private static final String TAG = "MainActivity";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
handleShareIntent(getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleShareIntent(intent);
|
||||
}
|
||||
|
||||
private void handleShareIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
|
||||
String action = intent.getAction();
|
||||
String type = intent.getType();
|
||||
|
||||
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
|
||||
Uri imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
if (imageUri != null) {
|
||||
handleSharedImage(imageUri, intent.getStringExtra(Intent.EXTRA_TEXT));
|
||||
}
|
||||
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
|
||||
// Handle multiple images (optional - for now just take first)
|
||||
java.util.ArrayList<Uri> imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
||||
if (imageUris != null && !imageUris.isEmpty()) {
|
||||
handleSharedImage(imageUris.get(0), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSharedImage(Uri imageUri, String fileName) {
|
||||
try {
|
||||
// Read image data
|
||||
InputStream inputStream = getContentResolver().openInputStream(imageUri);
|
||||
if (inputStream == null) {
|
||||
Log.e(TAG, "Failed to open input stream for shared image");
|
||||
return;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] data = new byte[8192];
|
||||
int nRead;
|
||||
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
|
||||
buffer.write(data, 0, nRead);
|
||||
}
|
||||
buffer.flush();
|
||||
byte[] imageBytes = buffer.toByteArray();
|
||||
|
||||
// Convert to base64
|
||||
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
|
||||
|
||||
// Extract filename from URI or use default
|
||||
String actualFileName = fileName;
|
||||
if (actualFileName == null || actualFileName.isEmpty()) {
|
||||
String path = imageUri.getPath();
|
||||
if (path != null) {
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
|
||||
actualFileName = path.substring(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
if (actualFileName == null || actualFileName.isEmpty()) {
|
||||
actualFileName = "shared-image.jpg";
|
||||
}
|
||||
}
|
||||
|
||||
// Store in SharedPreferences (accessible by JavaScript via Capacitor)
|
||||
android.content.SharedPreferences prefs = getSharedPreferences("TimeSafariShared", MODE_PRIVATE);
|
||||
android.content.SharedPreferences.Editor editor = prefs.edit();
|
||||
editor.putString("sharedPhotoBase64", base64String);
|
||||
editor.putString("sharedPhotoFileName", actualFileName);
|
||||
editor.apply();
|
||||
|
||||
// Trigger JavaScript event or navigate to shared-photo route
|
||||
// This will be handled by JavaScript checking for shared data on app launch
|
||||
Log.d(TAG, "Shared image stored, filename: " + actualFileName);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error handling shared image", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 Add Required Permissions
|
||||
|
||||
Ensure `AndroidManifest.xml` has:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- Android 13+ -->
|
||||
```
|
||||
|
||||
### 3. JavaScript Layer Updates
|
||||
|
||||
#### 3.1 Create Native Share Handler
|
||||
|
||||
Create `src/services/nativeShareHandler.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Native Share Handler
|
||||
* Handles shared images from native iOS and Android platforms
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { App } from "@capacitor/app";
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import { logger } from "../utils/logger";
|
||||
import { SHARED_PHOTO_BASE64_KEY } from "../libs/util";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
|
||||
/**
|
||||
* Check for shared images from native platforms and store in temp database
|
||||
*/
|
||||
export async function checkForNativeSharedImage(
|
||||
platformService: InstanceType<typeof PlatformServiceMixin>
|
||||
): Promise<boolean> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Capacitor.getPlatform() === "ios") {
|
||||
return await checkIOSSharedImage(platformService);
|
||||
} else if (Capacitor.getPlatform() === "android") {
|
||||
return await checkAndroidSharedImage(platformService);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error checking for native shared image:", error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for shared image on iOS (from App Group UserDefaults)
|
||||
*/
|
||||
async function checkIOSSharedImage(
|
||||
platformService: InstanceType<typeof PlatformServiceMixin>
|
||||
): Promise<boolean> {
|
||||
// iOS uses App Groups to share data between extension and main app
|
||||
// We need to use a Capacitor plugin or native code to read from App Group
|
||||
// For now, this is a placeholder - requires native plugin implementation
|
||||
|
||||
// Option 1: Use Capacitor plugin to read from App Group
|
||||
// Option 2: Use native code bridge
|
||||
|
||||
logger.debug("Checking for iOS shared image (not yet implemented)");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for shared image on Android (from SharedPreferences)
|
||||
*/
|
||||
async function checkAndroidSharedImage(
|
||||
platformService: InstanceType<typeof PlatformServiceMixin>
|
||||
): Promise<boolean> {
|
||||
// Android stores in SharedPreferences
|
||||
// We need a Capacitor plugin to read from SharedPreferences
|
||||
// For now, this is a placeholder - requires native plugin implementation
|
||||
|
||||
logger.debug("Checking for Android shared image (not yet implemented)");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store shared image in temp database
|
||||
*/
|
||||
async function storeSharedImage(
|
||||
base64Data: string,
|
||||
fileName: string,
|
||||
platformService: InstanceType<typeof PlatformServiceMixin>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existing = await platformService.$getTemp(SHARED_PHOTO_BASE64_KEY);
|
||||
|
||||
if (existing) {
|
||||
await platformService.$updateEntity(
|
||||
"temp",
|
||||
{ blobB64: base64Data },
|
||||
"id = ?",
|
||||
[SHARED_PHOTO_BASE64_KEY]
|
||||
);
|
||||
} else {
|
||||
await platformService.$insertEntity(
|
||||
"temp",
|
||||
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64Data },
|
||||
["id", "blobB64"]
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Stored shared image in temp database");
|
||||
} catch (error) {
|
||||
logger.error("Error storing shared image:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Update main.capacitor.ts
|
||||
|
||||
Add check for shared images on app launch:
|
||||
|
||||
```typescript
|
||||
// In main.capacitor.ts, after app mount:
|
||||
|
||||
import { checkForNativeSharedImage } from "./services/nativeShareHandler";
|
||||
|
||||
// Check for shared images when app becomes active
|
||||
App.addListener("appStateChange", async (state) => {
|
||||
if (state.isActive) {
|
||||
// Check for native shared images
|
||||
const hasSharedImage = await checkForNativeSharedImage(/* platformService */);
|
||||
if (hasSharedImage) {
|
||||
// Navigate to shared-photo view
|
||||
await router.push({
|
||||
name: "shared-photo",
|
||||
query: { source: "native" }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also check on initial launch
|
||||
App.getLaunchUrl().then((result) => {
|
||||
if (result?.url) {
|
||||
// Handle deep link
|
||||
} else {
|
||||
// Check for shared image
|
||||
checkForNativeSharedImage(/* platformService */).then((hasShared) => {
|
||||
if (hasShared) {
|
||||
router.push({ name: "shared-photo", query: { source: "native" } });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 3.3 Update SharedPhotoView.vue
|
||||
|
||||
The existing `SharedPhotoView.vue` should work as-is, but we may want to add detection for native vs web sources.
|
||||
|
||||
### 4. Alternative Approach: Capacitor Plugin
|
||||
|
||||
Instead of implementing native code directly, consider creating a Capacitor plugin:
|
||||
|
||||
1. **Create plugin**: `@capacitor-community/share-target` or custom plugin
|
||||
2. **Plugin methods**:
|
||||
- `checkForSharedImage()`: Returns shared image data if available
|
||||
- `clearSharedImage()`: Clears shared image data after processing
|
||||
|
||||
This would be cleaner and more maintainable.
|
||||
|
||||
### 5. Testing Checklist
|
||||
|
||||
- [ ] Test sharing image from Photos app on iOS
|
||||
- [ ] Test sharing image from Gallery app on Android
|
||||
- [ ] Test sharing from other apps (Safari, Chrome, etc.)
|
||||
- [ ] Verify image appears in SharedPhotoView
|
||||
- [ ] Test "Record Gift" flow with shared image
|
||||
- [ ] Test "Save as Profile" flow with shared image
|
||||
- [ ] Test cancel flow
|
||||
- [ ] Verify temp storage cleanup
|
||||
- [ ] Test app launch with shared image pending
|
||||
- [ ] Test app already running when image is shared
|
||||
|
||||
### 6. Implementation Priority
|
||||
|
||||
**Phase 1: Android (Simpler)**
|
||||
1. Update AndroidManifest.xml
|
||||
2. Implement MainActivity intent handling
|
||||
3. Create JavaScript handler
|
||||
4. Test end-to-end
|
||||
|
||||
**Phase 2: iOS (More Complex)**
|
||||
1. Create Share Extension target
|
||||
2. Implement ShareViewController
|
||||
3. Configure App Groups
|
||||
4. Create JavaScript handler
|
||||
5. Test end-to-end
|
||||
|
||||
### 7. Notes
|
||||
|
||||
- **App Groups (iOS)**: Required for sharing data between Share Extension and main app
|
||||
- **SharedPreferences (Android)**: Standard way to share data between app components
|
||||
- **Base64 Encoding**: Both platforms convert images to base64 for JavaScript compatibility
|
||||
- **File Size Limits**: Consider large image handling and memory management
|
||||
- **Permissions**: Android 13+ requires `READ_MEDIA_IMAGES` instead of `READ_EXTERNAL_STORAGE`
|
||||
|
||||
### 8. References
|
||||
|
||||
- [iOS Share Extensions](https://developer.apple.com/documentation/social)
|
||||
- [Android Share Targets](https://developer.android.com/training/sharing/receive)
|
||||
- [Capacitor App Plugin](https://capacitorjs.com/docs/apis/app)
|
||||
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)
|
||||
|
||||
76
doc/xcode-26-cocoapods-workaround.md
Normal file
76
doc/xcode-26-cocoapods-workaround.md
Normal 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.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 70;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -15,8 +15,35 @@
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
|
||||
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86585E72ED45A3D00824752 /* ShareImageBridge.swift */; };
|
||||
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
C86585DD2ED456DE00824752 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = C86585D42ED456DE00824752;
|
||||
remoteInfo = TimeSafariShareExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
C86585E02ED456DE00824752 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
@@ -28,10 +55,28 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImageBridge.swift; sourceTree = "<group>"; };
|
||||
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImagePlugin.swift; sourceTree = "<group>"; };
|
||||
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
@@ -41,6 +86,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C86585D22ED456DE00824752 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -56,6 +108,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3061FED79650016851F /* App */,
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
BA325FFCDCE8D334E5C7AEBE /* Pods */,
|
||||
4B546315E668C7A13939F417 /* Frameworks */,
|
||||
@@ -66,6 +119,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -73,6 +127,9 @@
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */,
|
||||
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */,
|
||||
C86585E52ED4577F00824752 /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
@@ -108,16 +165,40 @@
|
||||
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
||||
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
|
||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
|
||||
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
C86585DE2ED456DE00824752 /* PBXTargetDependency */,
|
||||
);
|
||||
name = App;
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
C86585D42ED456DE00824752 /* TimeSafariShareExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */;
|
||||
buildPhases = (
|
||||
C86585D12ED456DE00824752 /* Sources */,
|
||||
C86585D22ED456DE00824752 /* Frameworks */,
|
||||
C86585D32ED456DE00824752 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
|
||||
);
|
||||
name = TimeSafariShareExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = TimeSafariShareExtension;
|
||||
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -125,7 +206,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 920;
|
||||
LastSwiftUpdateCheck = 2610;
|
||||
LastUpgradeCheck = 1630;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
@@ -133,6 +214,9 @@
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
C86585D42ED456DE00824752 = {
|
||||
CreatedOnToolsVersion = 26.1.1;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
@@ -149,6 +233,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -167,6 +252,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C86585D32ED456DE00824752 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
@@ -253,12 +345,29 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */,
|
||||
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */,
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C86585D12ED456DE00824752 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
C86585DE2ED456DE00824752 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
|
||||
targetProxy = C86585DD2ED456DE00824752 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
@@ -402,8 +511,9 @@
|
||||
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -413,7 +523,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.1;
|
||||
MARKETING_VERSION = 1.1.3;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -429,8 +539,9 @@
|
||||
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -440,7 +551,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.1;
|
||||
MARKETING_VERSION = 1.1.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -450,6 +561,80 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C86585E12ED456DE00824752 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
C86585E22ED456DE00824752 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -471,6 +656,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C86585E12ED456DE00824752 /* Debug */,
|
||||
C86585E22ED456DE00824752 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
|
||||
77
ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme
Normal file
77
ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme
Normal 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>
|
||||
10
ios/App/App/App.entitlements
Normal file
10
ios/App/App/App.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.timesafari</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -39,6 +39,33 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
// Check if this is a shared-photo deep link and store image data in a way JS can access
|
||||
if url.scheme == "timesafari" && url.host == "shared-photo" {
|
||||
// Try to get shared image from App Group and store it in a temp file that JS can read
|
||||
// This is a workaround until the plugin is properly registered
|
||||
if let sharedData = getSharedImageData() {
|
||||
// Write to a temp file in the app's Documents directory that JavaScript can read via Filesystem plugin
|
||||
let fileManager = FileManager.default
|
||||
if let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
let tempFileURL = documentsDir.appendingPathComponent("timesafari_shared_photo.json")
|
||||
|
||||
// Create JSON data
|
||||
let jsonData: [String: String] = [
|
||||
"base64": sharedData["base64"] ?? "",
|
||||
"fileName": sharedData["fileName"] ?? ""
|
||||
]
|
||||
|
||||
if let json = try? JSONSerialization.data(withJSONObject: jsonData, options: []) {
|
||||
do {
|
||||
try json.write(to: tempFileURL)
|
||||
} catch {
|
||||
// Error writing temp file - will be handled by JS layer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called when the app was launched with a url. Feel free to add additional processing here,
|
||||
// but if you want the App API to support tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
|
||||
@@ -50,5 +77,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for shared image from Share Extension
|
||||
* Reads from App Group UserDefaults and returns shared image data if available
|
||||
*
|
||||
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
|
||||
*/
|
||||
func getSharedImageData() -> [String: String]? {
|
||||
let appGroupIdentifier = "group.app.timesafari"
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let base64 = userDefaults.string(forKey: "sharedPhotoBase64"),
|
||||
let fileName = userDefaults.string(forKey: "sharedPhotoFileName") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear the shared data after reading
|
||||
userDefaults.removeObject(forKey: "sharedPhotoBase64")
|
||||
userDefaults.removeObject(forKey: "sharedPhotoFileName")
|
||||
userDefaults.synchronize()
|
||||
|
||||
return ["base64": base64, "fileName": fileName]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
48
ios/App/App/ShareImageBridge.swift
Normal file
48
ios/App/App/ShareImageBridge.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
* Share Image Bridge
|
||||
*
|
||||
* Provides a bridge between JavaScript and native iOS code to access
|
||||
* shared images stored in App Group UserDefaults by the Share Extension.
|
||||
*
|
||||
* This bridge allows the JavaScript layer to read shared image data
|
||||
* that was stored by the Share Extension.
|
||||
*
|
||||
* Note: This class doesn't need Capacitor - it's a simple Swift utility
|
||||
* that reads from App Group UserDefaults. The JavaScript bridge will be
|
||||
* implemented separately.
|
||||
*/
|
||||
@objc(ShareImageBridge)
|
||||
public class ShareImageBridge: NSObject {
|
||||
|
||||
private static let appGroupIdentifier = "group.app.timesafari"
|
||||
private static let sharedPhotoBase64Key = "sharedPhotoBase64"
|
||||
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
|
||||
/**
|
||||
* Get shared image data from App Group UserDefaults
|
||||
* Called from JavaScript via Capacitor bridge
|
||||
*
|
||||
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
|
||||
*/
|
||||
@objc public static func getSharedImageData() -> [String: String]? {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
print("ShareImageBridge: Failed to access App Group UserDefaults")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let base64 = userDefaults.string(forKey: sharedPhotoBase64Key),
|
||||
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear the shared data after reading
|
||||
userDefaults.removeObject(forKey: sharedPhotoBase64Key)
|
||||
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
|
||||
userDefaults.synchronize()
|
||||
|
||||
return ["base64": base64, "fileName": fileName]
|
||||
}
|
||||
}
|
||||
|
||||
28
ios/App/App/ShareImagePlugin.swift
Normal file
28
ios/App/App/ShareImagePlugin.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
|
||||
/**
|
||||
* Share Image Plugin
|
||||
*
|
||||
* Capacitor plugin that exposes ShareImageBridge functionality to JavaScript.
|
||||
* Allows JavaScript to retrieve shared images from App Group UserDefaults.
|
||||
*/
|
||||
@objc(ShareImagePlugin)
|
||||
public class ShareImagePlugin: CAPPlugin {
|
||||
|
||||
@objc func getSharedImageData(_ call: CAPPluginCall) {
|
||||
guard let sharedData = ShareImageBridge.getSharedImageData() else {
|
||||
call.resolve(["success": false, "data": NSNull()])
|
||||
return
|
||||
}
|
||||
|
||||
call.resolve([
|
||||
"success": true,
|
||||
"data": [
|
||||
"base64": sharedData["base64"] ?? "",
|
||||
"fileName": sharedData["fileName"] ?? ""
|
||||
]
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
21
ios/App/TimeSafariShareExtension/Info.plist
Normal file
21
ios/App/TimeSafariShareExtension/Info.plist
Normal 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>
|
||||
170
ios/App/TimeSafariShareExtension/ShareViewController.swift
Normal file
170
ios/App/TimeSafariShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,170 @@
|
||||
//
|
||||
// ShareViewController.swift
|
||||
// TimeSafariShareExtension
|
||||
//
|
||||
// Created by Aardimus on 11/24/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Social
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
|
||||
private let appGroupIdentifier = "group.app.timesafari"
|
||||
private let sharedPhotoBase64Key = "sharedPhotoBase64"
|
||||
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Set placeholder text (required for SLComposeServiceViewController)
|
||||
self.placeholder = "Share image to TimeSafari"
|
||||
|
||||
// Validate content on load
|
||||
self.validateContent()
|
||||
}
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
// Validate that we have image attachments
|
||||
guard let extensionContext = extensionContext else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
|
||||
return false
|
||||
}
|
||||
|
||||
for item in inputItems {
|
||||
if let attachments = item.attachments {
|
||||
for attachment in attachments {
|
||||
if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
// Extract and process the shared image
|
||||
guard let extensionContext = extensionContext else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
|
||||
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Process the first image found
|
||||
processSharedImage(from: inputItems) { [weak self] success in
|
||||
guard let self = self else {
|
||||
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
// Open the main app via deep link
|
||||
self.openMainApp()
|
||||
}
|
||||
|
||||
// Complete the extension context
|
||||
extensionContext.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
|
||||
// Find the first image attachment
|
||||
for item in items {
|
||||
guard let attachments = item.attachments else {
|
||||
continue
|
||||
}
|
||||
|
||||
for attachment in attachments {
|
||||
if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
|
||||
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
|
||||
guard let self = self else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different image data types
|
||||
var imageData: Data?
|
||||
var fileName: String = "shared-image.jpg"
|
||||
|
||||
if let url = data as? URL {
|
||||
// Image provided as file URL
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
} else if let image = data as? UIImage {
|
||||
// Image provided as UIImage
|
||||
imageData = image.jpegData(compressionQuality: 0.9)
|
||||
fileName = "shared-image.jpg"
|
||||
} else if let data = data as? Data {
|
||||
// Image provided as raw Data
|
||||
imageData = data
|
||||
fileName = "shared-image.jpg"
|
||||
}
|
||||
|
||||
guard let finalImageData = imageData else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
let base64String = finalImageData.base64EncodedString()
|
||||
|
||||
// Store in App Group UserDefaults
|
||||
guard let userDefaults = UserDefaults(suiteName: self.appGroupIdentifier) else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
userDefaults.set(base64String, forKey: self.sharedPhotoBase64Key)
|
||||
userDefaults.set(fileName, forKey: self.sharedPhotoFileNameKey)
|
||||
userDefaults.synchronize()
|
||||
|
||||
completion(true)
|
||||
}
|
||||
return // Process only the first image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No image found
|
||||
completion(false)
|
||||
}
|
||||
|
||||
private func openMainApp() {
|
||||
// Open the main app via deep link
|
||||
guard let url = URL(string: "timesafari://shared-photo") else {
|
||||
return
|
||||
}
|
||||
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
|
||||
// Fallback: use extension context
|
||||
extensionContext?.open(url, completionHandler: nil)
|
||||
}
|
||||
|
||||
override func configurationItems() -> [Any]! {
|
||||
// No additional configuration options needed
|
||||
return []
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.timesafari</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "1.1.2-beta",
|
||||
"version": "1.1.4-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "timesafari",
|
||||
"version": "1.1.2-beta",
|
||||
"version": "1.1.4-beta",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor-community/sqlite": "6.0.2",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@ethersproject/wallet": "^5.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
@@ -6789,6 +6790,25 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-brands-svg-icons": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz",
|
||||
"integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-brands-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
|
||||
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-regular-svg-icons": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "1.1.2-beta",
|
||||
"version": "1.1.4-beta",
|
||||
"description": "Time Safari Application",
|
||||
"author": {
|
||||
"name": "Time Safari Team"
|
||||
@@ -156,6 +156,7 @@
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@ethersproject/wallet": "^5.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
|
||||
@@ -436,7 +436,21 @@ fi
|
||||
log_info "Cleaning dist directory..."
|
||||
clean_build_artifacts "dist"
|
||||
|
||||
# Step 4: Build Capacitor version with mode
|
||||
# Step 4: Run TypeScript type checking for test and production builds
|
||||
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
|
||||
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
|
||||
|
||||
if ! measure_time npm run type-check; then
|
||||
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log_success "TypeScript type checking completed for $BUILD_MODE mode"
|
||||
else
|
||||
log_debug "Skipping TypeScript type checking for development mode"
|
||||
fi
|
||||
|
||||
# Step 5: Build Capacitor version with mode
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||
elif [ "$BUILD_MODE" = "test" ]; then
|
||||
@@ -445,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
|
||||
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||
fi
|
||||
|
||||
# Step 5: Clean Gradle build
|
||||
# Step 6: Clean Gradle build
|
||||
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
|
||||
|
||||
# Step 6: Build based on type
|
||||
# Step 7: Build based on type
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
||||
fi
|
||||
|
||||
# Step 7: Sync with Capacitor
|
||||
# Step 8: Sync with Capacitor
|
||||
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
|
||||
|
||||
# Step 8: Generate assets
|
||||
# Step 9: Generate assets
|
||||
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
|
||||
|
||||
# Step 9: Build APK/AAB if requested
|
||||
# Step 10: Build APK/AAB if requested
|
||||
if [ "$BUILD_APK" = true ]; then
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||
@@ -474,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
|
||||
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
|
||||
fi
|
||||
|
||||
# Step 10: Auto-run app if requested
|
||||
# Step 11: Auto-run app if requested
|
||||
if [ "$AUTO_RUN" = true ]; then
|
||||
log_step "Auto-running Android app..."
|
||||
safe_execute "Launching app" "npx cap run android" || {
|
||||
@@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
|
||||
log_success "Android app launched successfully!"
|
||||
fi
|
||||
|
||||
# Step 11: Open Android Studio if requested
|
||||
# Step 12: Open Android Studio if requested
|
||||
if [ "$OPEN_STUDIO" = true ]; then
|
||||
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -76,7 +76,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
</template>
|
||||
|
||||
<!-- Empty state message -->
|
||||
<li v-if="entities.length === 0" :class="emptyStateClasses">
|
||||
<li v-if="hasNoEntities" :class="emptyStateClasses">
|
||||
{{ emptyStateMessage }}
|
||||
</li>
|
||||
|
||||
@@ -164,6 +164,10 @@ import { Contact } from "../db/tables/contacts";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { getHeaders } from "../libs/endorserServer";
|
||||
import { logger } from "../utils/logger";
|
||||
import { TIMEOUTS } from "@/utils/notify";
|
||||
|
||||
/**
|
||||
* Constants for infinite scroll configuration
|
||||
@@ -191,6 +195,7 @@ const RECENT_CONTACTS_COUNT = 3;
|
||||
ProjectCard,
|
||||
SpecialEntityCard,
|
||||
},
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class EntityGrid extends Vue {
|
||||
/** Type of entities to display */
|
||||
@@ -202,6 +207,16 @@ export default class EntityGrid extends Vue {
|
||||
isSearching = false;
|
||||
searchTimeout: NodeJS.Timeout | null = null;
|
||||
filteredEntities: Contact[] | PlanData[] = [];
|
||||
searchBeforeId: string | undefined = undefined;
|
||||
isLoadingSearchMore = false;
|
||||
|
||||
// API server for project searches
|
||||
apiServer = "";
|
||||
|
||||
// Internal project state (when entities prop not provided for projects)
|
||||
allProjects: PlanData[] = [];
|
||||
loadBeforeId: string | undefined = undefined;
|
||||
isLoadingProjects = false;
|
||||
|
||||
// Infinite scroll state
|
||||
displayedCount = INITIAL_BATCH_SIZE;
|
||||
@@ -211,17 +226,17 @@ export default class EntityGrid extends Vue {
|
||||
/**
|
||||
* Array of entities to display
|
||||
*
|
||||
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
|
||||
* Use $contactsByDateAdded() to ensure all contacts are included.
|
||||
* Client-side filtering assumes the complete list is available.
|
||||
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
|
||||
* (newest first) for the "Recently Added" section to display correctly.
|
||||
* Use $contactsByDateAdded() instead of $getAllContacts() or $contacts().
|
||||
*
|
||||
* The recentContacts computed property assumes contacts are already sorted
|
||||
* by date added and simply takes the first 3. If contacts are sorted
|
||||
* alphabetically or in another order, the wrong contacts will appear in
|
||||
* "Recently Added".
|
||||
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
|
||||
* projects internally from the API server. If provided, uses the provided list.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
entities!: Contact[] | PlanData[];
|
||||
@Prop({ required: false })
|
||||
entities?: Contact[] | PlanData[];
|
||||
|
||||
/** Active user's DID */
|
||||
@Prop({ required: true })
|
||||
@@ -293,6 +308,33 @@ export default class EntityGrid extends Vue {
|
||||
return "text-xs text-slate-500 italic col-span-full";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are no entities to display
|
||||
*/
|
||||
get hasNoEntities(): boolean {
|
||||
if (this.entityType === "projects") {
|
||||
// For projects: check internal state if no entities prop, otherwise check prop
|
||||
const projectsToCheck = this.entities || this.allProjects;
|
||||
return projectsToCheck.length === 0;
|
||||
} else {
|
||||
// For people: entities prop is required
|
||||
return !this.entities || this.entities.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entities array to use (prop or internal state)
|
||||
*/
|
||||
get entitiesToUse(): Contact[] | PlanData[] {
|
||||
if (this.entityType === "projects") {
|
||||
// For projects: use prop if provided, otherwise use internal state
|
||||
return this.entities || this.allProjects;
|
||||
} else {
|
||||
// For people: entities prop is required
|
||||
return this.entities || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
|
||||
* When searching, returns filtered results with infinite scroll applied
|
||||
@@ -305,12 +347,12 @@ export default class EntityGrid extends Vue {
|
||||
|
||||
// If custom function provided, use it (disables infinite scroll)
|
||||
if (this.displayEntitiesFunction) {
|
||||
return this.displayEntitiesFunction(this.entities, this.entityType);
|
||||
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
|
||||
}
|
||||
|
||||
// Default: projects use infinite scroll
|
||||
if (this.entityType === "projects") {
|
||||
return (this.entities as PlanData[]).slice(0, this.displayedCount);
|
||||
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
||||
}
|
||||
|
||||
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
||||
@@ -324,7 +366,11 @@ export default class EntityGrid extends Vue {
|
||||
* See the entities prop documentation for details on using $contactsByDateAdded().
|
||||
*/
|
||||
get recentContacts(): Contact[] {
|
||||
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
||||
if (
|
||||
this.entityType !== "people" ||
|
||||
this.searchTerm.trim() ||
|
||||
!this.entities
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
// Entities are already sorted by date added (newest first)
|
||||
@@ -336,7 +382,11 @@ export default class EntityGrid extends Vue {
|
||||
* Uses infinite scroll to control how many are displayed
|
||||
*/
|
||||
get alphabeticalContacts(): Contact[] {
|
||||
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
||||
if (
|
||||
this.entityType !== "people" ||
|
||||
this.searchTerm.trim() ||
|
||||
!this.entities
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
// Skip the first few (recent contacts) and sort the rest alphabetically
|
||||
@@ -457,47 +507,28 @@ export default class EntityGrid extends Vue {
|
||||
|
||||
/**
|
||||
* Perform the actual search
|
||||
* Routes to server-side search for projects or client-side filtering for contacts
|
||||
*/
|
||||
async performSearch(): Promise<void> {
|
||||
if (!this.searchTerm.trim()) {
|
||||
this.filteredEntities = [];
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.searchBeforeId = undefined;
|
||||
this.infiniteScrollReset?.();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSearching = true;
|
||||
this.searchBeforeId = undefined; // Reset pagination for new search
|
||||
|
||||
try {
|
||||
// Simulate async search (in case we need to add API calls later)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||
|
||||
if (this.entityType === "people") {
|
||||
this.filteredEntities = (this.entities as Contact[])
|
||||
.filter((contact: Contact) => {
|
||||
const name = contact.name?.toLowerCase() || "";
|
||||
const did = contact.did.toLowerCase();
|
||||
return name.includes(searchLower) || did.includes(searchLower);
|
||||
})
|
||||
.sort((a: Contact, b: Contact) => {
|
||||
// Sort alphabetically by name, falling back to DID if name is missing
|
||||
const nameA = (a.name || a.did).toLowerCase();
|
||||
const nameB = (b.name || b.did).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
if (this.entityType === "projects") {
|
||||
// Server-side search for projects (initial load, no beforeId)
|
||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||
await this.fetchProjects(undefined, searchLower);
|
||||
} else {
|
||||
this.filteredEntities = (this.entities as PlanData[])
|
||||
.filter((project: PlanData) => {
|
||||
const name = project.name?.toLowerCase() || "";
|
||||
const handleId = project.handleId.toLowerCase();
|
||||
return name.includes(searchLower) || handleId.includes(searchLower);
|
||||
})
|
||||
.sort((a: PlanData, b: PlanData) => {
|
||||
// Sort alphabetically by name
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
// Client-side filtering for contacts (complete list)
|
||||
await this.performContactSearch();
|
||||
}
|
||||
|
||||
// Reset displayed count when search completes
|
||||
@@ -508,6 +539,194 @@ export default class EntityGrid extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch projects from API server
|
||||
* Unified method for both loading all projects and searching projects.
|
||||
* If claimContents is provided, performs search and updates filteredEntities.
|
||||
* If claimContents is not provided, loads all projects and updates allProjects.
|
||||
*
|
||||
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
||||
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
|
||||
*/
|
||||
async fetchProjects(
|
||||
beforeId?: string,
|
||||
claimContents?: string,
|
||||
): Promise<void> {
|
||||
if (!this.apiServer) {
|
||||
if (claimContents) {
|
||||
this.filteredEntities = [];
|
||||
} else {
|
||||
this.allProjects = [];
|
||||
}
|
||||
if (this.notify) {
|
||||
this.notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "API server not configured",
|
||||
},
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isSearch = !!claimContents;
|
||||
let url = `${this.apiServer}/api/v2/report/plans`;
|
||||
|
||||
// Build query parameters
|
||||
const params: string[] = [];
|
||||
if (claimContents) {
|
||||
params.push(
|
||||
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
|
||||
);
|
||||
}
|
||||
if (beforeId) {
|
||||
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
|
||||
}
|
||||
if (params.length > 0) {
|
||||
url += `?${params.join("&")}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: await getHeaders(this.activeDid),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
isSearch ? "Failed to search projects" : "Failed to load projects",
|
||||
);
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
if (results.data) {
|
||||
const newProjects = results.data.map(
|
||||
(plan: PlanData & { rowId?: string }) => ({
|
||||
...plan,
|
||||
rowId: plan.rowId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isSearch) {
|
||||
// Search mode: update filteredEntities
|
||||
if (beforeId) {
|
||||
// Pagination: append new projects to existing search results
|
||||
this.filteredEntities.push(...newProjects);
|
||||
} else {
|
||||
// Initial search: replace array
|
||||
this.filteredEntities = newProjects;
|
||||
}
|
||||
|
||||
// Update searchBeforeId for next pagination
|
||||
if (newProjects.length > 0) {
|
||||
const lastProject = newProjects[newProjects.length - 1];
|
||||
this.searchBeforeId = lastProject.rowId || undefined;
|
||||
} else {
|
||||
this.searchBeforeId = undefined; // No more results
|
||||
}
|
||||
} else {
|
||||
// Load mode: update allProjects
|
||||
if (beforeId) {
|
||||
// Pagination: append new projects
|
||||
this.allProjects.push(...newProjects);
|
||||
} else {
|
||||
// Initial load: replace array
|
||||
this.allProjects = newProjects;
|
||||
}
|
||||
|
||||
// Update loadBeforeId for next pagination
|
||||
if (newProjects.length > 0) {
|
||||
const lastProject = newProjects[newProjects.length - 1];
|
||||
this.loadBeforeId = lastProject.rowId || undefined;
|
||||
} else {
|
||||
this.loadBeforeId = undefined; // No more results
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No data in response
|
||||
if (isSearch) {
|
||||
if (!beforeId) {
|
||||
// Only clear on initial search, not pagination
|
||||
this.filteredEntities = [];
|
||||
}
|
||||
this.searchBeforeId = undefined;
|
||||
} else {
|
||||
if (!beforeId) {
|
||||
// Only clear on initial load, not pagination
|
||||
this.allProjects = [];
|
||||
}
|
||||
this.loadBeforeId = undefined;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error ${isSearch ? "searching" : "loading"} projects:`,
|
||||
error,
|
||||
);
|
||||
if (isSearch) {
|
||||
if (!beforeId) {
|
||||
// Only clear on initial search error, not pagination error
|
||||
this.filteredEntities = [];
|
||||
}
|
||||
this.searchBeforeId = undefined;
|
||||
} else {
|
||||
if (!beforeId) {
|
||||
// Only clear on initial load error, not pagination error
|
||||
this.allProjects = [];
|
||||
}
|
||||
this.loadBeforeId = undefined;
|
||||
}
|
||||
if (this.notify) {
|
||||
this.notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: isSearch
|
||||
? "Failed to search projects. Please try again."
|
||||
: "Failed to load projects. Please try again.",
|
||||
},
|
||||
TIMEOUTS.STANDARD,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side contact search
|
||||
* Assumes entities prop contains complete contact list from local database
|
||||
*/
|
||||
async performContactSearch(): Promise<void> {
|
||||
if (!this.entities) {
|
||||
this.filteredEntities = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate async (for consistency with project search)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||
|
||||
this.filteredEntities = (this.entities as Contact[])
|
||||
.filter((contact: Contact) => {
|
||||
const name = contact.name?.toLowerCase() || "";
|
||||
const did = contact.did.toLowerCase();
|
||||
return name.includes(searchLower) || did.includes(searchLower);
|
||||
})
|
||||
.sort((a: Contact, b: Contact) => {
|
||||
// Sort alphabetically by name, falling back to DID if name is missing
|
||||
const nameA = (a.name || a.did).toLowerCase();
|
||||
const nameB = (b.name || b.did).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
// Contacts don't need pagination (complete list)
|
||||
this.searchBeforeId = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search
|
||||
*/
|
||||
@@ -516,6 +735,7 @@ export default class EntityGrid extends Vue {
|
||||
this.filteredEntities = [];
|
||||
this.isSearching = false;
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.searchBeforeId = undefined;
|
||||
this.infiniteScrollReset?.();
|
||||
|
||||
// Clear any pending timeout
|
||||
@@ -535,17 +755,48 @@ export default class EntityGrid extends Vue {
|
||||
}
|
||||
|
||||
if (this.searchTerm.trim()) {
|
||||
// Search mode: check filtered entities
|
||||
return this.displayedCount < this.filteredEntities.length;
|
||||
// Search mode: check if more results available
|
||||
if (this.entityType === "projects") {
|
||||
// Projects: can load more if:
|
||||
// 1. We have more already-loaded results to show, OR
|
||||
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
|
||||
const hasMoreLoaded =
|
||||
this.displayedCount < this.filteredEntities.length;
|
||||
const canLoadMoreFromServer =
|
||||
this.displayedCount >= this.filteredEntities.length &&
|
||||
!!this.searchBeforeId &&
|
||||
!this.isLoadingSearchMore;
|
||||
return hasMoreLoaded || canLoadMoreFromServer;
|
||||
} else {
|
||||
// Contacts: client-side filtering returns all results at once
|
||||
return this.displayedCount < this.filteredEntities.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-search mode
|
||||
if (this.entityType === "projects") {
|
||||
// Projects: check if more available
|
||||
return this.displayedCount < this.entities.length;
|
||||
// Projects: check internal state or prop
|
||||
const projectsToCheck = this.entities || this.allProjects;
|
||||
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
||||
|
||||
// Can load more if:
|
||||
// 1. We have more already-loaded results to show, OR
|
||||
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
|
||||
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
|
||||
const canLoadMoreFromServer =
|
||||
!this.entities &&
|
||||
this.displayedCount >= projectsToCheck.length &&
|
||||
!!beforeId &&
|
||||
!this.isLoadingProjects;
|
||||
|
||||
return hasMoreLoaded || canLoadMoreFromServer;
|
||||
}
|
||||
|
||||
// People: check if more alphabetical contacts available
|
||||
// Total available = recent + all alphabetical
|
||||
if (!this.entities) {
|
||||
return false;
|
||||
}
|
||||
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
|
||||
return this.displayedCount < totalAvailable;
|
||||
}
|
||||
@@ -553,16 +804,112 @@ export default class EntityGrid extends Vue {
|
||||
/**
|
||||
* Initialize infinite scroll on mount
|
||||
*/
|
||||
mounted(): void {
|
||||
async mounted(): Promise<void> {
|
||||
// Load apiServer for project searches/loads
|
||||
if (this.entityType === "projects") {
|
||||
const settings = await this.$accountSettings();
|
||||
this.apiServer = settings.apiServer || "";
|
||||
|
||||
// Load projects on mount if entities prop not provided
|
||||
if (!this.entities && this.apiServer) {
|
||||
this.isLoadingProjects = true;
|
||||
try {
|
||||
await this.fetchProjects();
|
||||
} catch (error) {
|
||||
logger.error("Error loading projects on mount:", error);
|
||||
} finally {
|
||||
this.isLoadingProjects = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate entities prop for people
|
||||
if (this.entityType === "people" && !this.entities) {
|
||||
logger.error(
|
||||
"EntityGrid: entities prop is required when entityType is 'people'",
|
||||
);
|
||||
if (this.notify) {
|
||||
this.notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Contacts data is required but not provided.",
|
||||
},
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.scrollContainer as HTMLElement;
|
||||
|
||||
if (container) {
|
||||
const { reset } = useInfiniteScroll(
|
||||
container,
|
||||
() => {
|
||||
// Load more: increment displayedCount
|
||||
this.displayedCount += INCREMENT_SIZE;
|
||||
async () => {
|
||||
// Search mode: handle search pagination
|
||||
if (this.searchTerm.trim()) {
|
||||
if (this.entityType === "projects") {
|
||||
// Projects: load more search results if available
|
||||
if (
|
||||
this.displayedCount >= this.filteredEntities.length &&
|
||||
this.searchBeforeId &&
|
||||
!this.isLoadingSearchMore
|
||||
) {
|
||||
this.isLoadingSearchMore = true;
|
||||
try {
|
||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||
await this.fetchProjects(this.searchBeforeId, searchLower);
|
||||
// After loading more, reset scroll state to allow further loading
|
||||
this.infiniteScrollReset?.();
|
||||
} catch (error) {
|
||||
logger.error("Error loading more search results:", error);
|
||||
// Error already handled in fetchProjects
|
||||
} finally {
|
||||
this.isLoadingSearchMore = false;
|
||||
}
|
||||
} else {
|
||||
// Show more from already-loaded search results
|
||||
this.displayedCount += INCREMENT_SIZE;
|
||||
}
|
||||
} else {
|
||||
// Contacts: show more from already-filtered results
|
||||
this.displayedCount += INCREMENT_SIZE;
|
||||
}
|
||||
} else {
|
||||
// Non-search mode
|
||||
if (this.entityType === "projects") {
|
||||
const projectsToCheck = this.entities || this.allProjects;
|
||||
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
||||
|
||||
// If using internal state and need to load more from server
|
||||
if (
|
||||
!this.entities &&
|
||||
this.displayedCount >= projectsToCheck.length &&
|
||||
beforeId &&
|
||||
!this.isLoadingProjects
|
||||
) {
|
||||
this.isLoadingProjects = true;
|
||||
try {
|
||||
await this.fetchProjects(beforeId);
|
||||
// After loading more, reset scroll state to allow further loading
|
||||
this.infiniteScrollReset?.();
|
||||
} catch (error) {
|
||||
logger.error("Error loading more projects:", error);
|
||||
// Error already handled in fetchProjects
|
||||
} finally {
|
||||
this.isLoadingProjects = false;
|
||||
}
|
||||
} else {
|
||||
// Normal case: increment displayedCount to show more from memory
|
||||
this.displayedCount += INCREMENT_SIZE;
|
||||
}
|
||||
} else {
|
||||
// People: increment displayedCount to show more from memory
|
||||
this.displayedCount += INCREMENT_SIZE;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
distance: 50, // pixels from bottom
|
||||
@@ -588,21 +935,35 @@ export default class EntityGrid extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for changes in search term to reset displayed count
|
||||
* Watch for changes in search term to reset displayed count and pagination
|
||||
*/
|
||||
@Watch("searchTerm")
|
||||
onSearchTermChange(): void {
|
||||
// Reset displayed count and pagination when search term changes
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.searchBeforeId = undefined;
|
||||
this.infiniteScrollReset?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for changes in entities prop to reset displayed count
|
||||
* Watch for changes in entities prop to clear search and reset displayed count
|
||||
*/
|
||||
@Watch("entities")
|
||||
onEntitiesChange(): void {
|
||||
// Clear search when entities change (fresh dialog open)
|
||||
if (this.searchTerm) {
|
||||
this.searchTerm = "";
|
||||
this.filteredEntities = [];
|
||||
this.searchBeforeId = undefined;
|
||||
}
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.infiniteScrollReset?.();
|
||||
|
||||
// For projects: if entities prop is provided, clear internal state
|
||||
if (this.entityType === "projects" && this.entities) {
|
||||
this.allProjects = [];
|
||||
this.loadBeforeId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ properties * * @author Matthew Raymer */
|
||||
|
||||
<EntityGrid
|
||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||
:entities="shouldShowProjects ? projects : allContacts"
|
||||
:entities="shouldShowProjects ? projects || undefined : allContacts"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
@@ -94,9 +94,9 @@ export default class EntitySelectionStep extends Vue {
|
||||
@Prop({ default: false })
|
||||
isFromProjectView!: boolean;
|
||||
|
||||
/** Array of available projects */
|
||||
@Prop({ required: true })
|
||||
projects!: PlanData[];
|
||||
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
|
||||
@Prop({ required: false })
|
||||
projects?: PlanData[];
|
||||
|
||||
/** Array of available contacts */
|
||||
@Prop({ required: true })
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
giverEntityType === 'project' || recipientEntityType === 'project'
|
||||
"
|
||||
:is-from-project-view="isFromProjectView"
|
||||
:projects="projects"
|
||||
:all-contacts="allContacts"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
@@ -68,7 +67,6 @@ import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
serverMessageForUser,
|
||||
getHeaders,
|
||||
} from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
@@ -134,7 +132,6 @@ export default class GiftedDialog extends Vue {
|
||||
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
||||
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||
offerId = "";
|
||||
projects: PlanData[] = [];
|
||||
prompt = "";
|
||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||
stepType = "giver";
|
||||
@@ -234,16 +231,6 @@ export default class GiftedDialog extends Vue {
|
||||
this.allContacts = await this.$contactsByDateAdded();
|
||||
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
|
||||
if (
|
||||
this.giverEntityType === "project" ||
|
||||
this.recipientEntityType === "project"
|
||||
) {
|
||||
await this.loadProjects();
|
||||
} else {
|
||||
// Clear projects array when not needed
|
||||
this.projects = [];
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error("Error retrieving settings from database:", err);
|
||||
this.safeNotify.error(
|
||||
@@ -489,27 +476,6 @@ export default class GiftedDialog extends Vue {
|
||||
this.firstStep = false;
|
||||
}
|
||||
|
||||
async loadProjects() {
|
||||
try {
|
||||
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
|
||||
method: "GET",
|
||||
headers: await getHeaders(this.activeDid),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Failed to load projects");
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
if (results.data) {
|
||||
this.projects = results.data;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error loading projects:", error);
|
||||
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
|
||||
}
|
||||
}
|
||||
|
||||
selectProject(project: PlanData) {
|
||||
this.giver = {
|
||||
did: project.handleId,
|
||||
@@ -517,10 +483,13 @@ export default class GiftedDialog extends Vue {
|
||||
image: project.image,
|
||||
handleId: project.handleId,
|
||||
};
|
||||
this.receiver = {
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
};
|
||||
// Only set receiver to "You" if no receiver has been selected yet
|
||||
if (!this.receiver || !this.receiver.did) {
|
||||
this.receiver = {
|
||||
did: this.activeDid,
|
||||
name: "You",
|
||||
};
|
||||
}
|
||||
this.firstStep = false;
|
||||
}
|
||||
|
||||
|
||||
130
src/components/MeetingProjectDialog.vue
Normal file
130
src/components/MeetingProjectDialog.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<!-- Header -->
|
||||
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
|
||||
|
||||
<!-- EntityGrid for projects -->
|
||||
<EntityGrid
|
||||
:entity-type="'projects'"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:conflict-checker="() => false"
|
||||
:show-you-entity="false"
|
||||
:show-unnamed-entity="false"
|
||||
:notify="notify"
|
||||
:conflict-context="'project'"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
|
||||
<!-- Cancel Button -->
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import EntityGrid from "./EntityGrid.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
|
||||
/**
|
||||
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
|
||||
*
|
||||
* Features:
|
||||
* - EntityGrid integration for project selection
|
||||
* - No special entities (You, Unnamed)
|
||||
* - Immediate assignment on project selection
|
||||
* - Cancel button to close without selection
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
EntityGrid,
|
||||
},
|
||||
})
|
||||
export default class MeetingProjectDialog extends Vue {
|
||||
/** Whether the dialog is visible */
|
||||
visible = false;
|
||||
|
||||
/** Active user's DID */
|
||||
@Prop({ required: true })
|
||||
activeDid!: string;
|
||||
|
||||
/** All user's DIDs */
|
||||
@Prop({ required: true })
|
||||
allMyDids!: string[];
|
||||
|
||||
/** All contacts */
|
||||
@Prop({ required: true })
|
||||
allContacts!: Contact[];
|
||||
|
||||
/** Notification function from parent component */
|
||||
@Prop()
|
||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
/**
|
||||
* Handle entity selection from EntityGrid
|
||||
* Immediately assigns the selected project and closes the dialog
|
||||
*/
|
||||
handleEntitySelected(event: {
|
||||
type: "person" | "project";
|
||||
data: Contact | PlanData;
|
||||
}) {
|
||||
const project = event.data as PlanData;
|
||||
this.emitAssign(project);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel button click
|
||||
*/
|
||||
handleCancel(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dialog
|
||||
*/
|
||||
open(): void {
|
||||
this.visible = true;
|
||||
this.emitOpen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dialog
|
||||
*/
|
||||
close(): void {
|
||||
this.visible = false;
|
||||
this.emitClose();
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("assign")
|
||||
emitAssign(project: PlanData): PlanData {
|
||||
return project;
|
||||
}
|
||||
|
||||
@Emit("open")
|
||||
emitOpen(): void {
|
||||
// Emit when dialog opens
|
||||
}
|
||||
|
||||
@Emit("close")
|
||||
emitClose(): void {
|
||||
// Emit when dialog closes
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -8,7 +8,7 @@ issuer information. * * @author Matthew Raymer */
|
||||
>
|
||||
<ProjectIcon
|
||||
:entity-id="project.handleId"
|
||||
:icon-size="48"
|
||||
:icon-size="30"
|
||||
:image-url="project.image"
|
||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||
/>
|
||||
|
||||
29
src/constants/contacts.ts
Normal file
29
src/constants/contacts.ts
Normal 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;
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export const deepLinkPathSchemas = {
|
||||
"user-profile": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"shared-photo": z.object({}),
|
||||
};
|
||||
|
||||
export const deepLinkQuerySchemas = {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -30,11 +30,15 @@
|
||||
|
||||
import { initializeApp } from "./main.common";
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import router from "./router";
|
||||
import { handleApiError } from "./services/api";
|
||||
import { AxiosError } from "axios";
|
||||
import { DeepLinkHandler } from "./services/deepLinks";
|
||||
import { logger, safeStringify } from "./utils/logger";
|
||||
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
|
||||
import { SHARED_PHOTO_BASE64_KEY } from "./libs/util";
|
||||
import "./utils/safeAreaInset";
|
||||
|
||||
logger.log("[Capacitor] 🚀 Starting initialization");
|
||||
@@ -67,11 +71,220 @@ const deepLinkHandler = new DeepLinkHandler(router);
|
||||
*
|
||||
* @throws {Error} If URL format is invalid
|
||||
*/
|
||||
/**
|
||||
* Check for native shared image from iOS App Group UserDefaults
|
||||
* and store in temp database before routing to shared-photo view
|
||||
*/
|
||||
async function checkAndStoreNativeSharedImage(): Promise<{
|
||||
success: boolean;
|
||||
fileName?: string;
|
||||
}> {
|
||||
if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("[Main] Checking for iOS shared image from App Group");
|
||||
|
||||
// Use Capacitor's native bridge to call the ShareImagePlugin
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const capacitor = (window as any).Capacitor;
|
||||
|
||||
if (!capacitor || !capacitor.Plugins) {
|
||||
logger.debug("[Main] Capacitor plugins not available");
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// WORKAROUND: Since the plugin isn't being auto-discovered, use a temp file bridge
|
||||
// AppDelegate writes the shared image data to a temp file, and we read it here
|
||||
try {
|
||||
const tempFilePath = "timesafari_shared_photo.json";
|
||||
const fileContent = await Filesystem.readFile({
|
||||
path: tempFilePath,
|
||||
directory: Directory.Documents,
|
||||
encoding: Encoding.UTF8,
|
||||
});
|
||||
|
||||
if (fileContent.data) {
|
||||
const sharedData = JSON.parse(fileContent.data as string);
|
||||
const base64 = sharedData.base64;
|
||||
const fileName = sharedData.fileName || "shared-image.jpg";
|
||||
|
||||
if (base64) {
|
||||
// Store in temp database using dbExec directly
|
||||
logger.info(
|
||||
"[Main] Native shared image found (via temp file), storing in temp DB",
|
||||
);
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
// Convert raw base64 to data URL format that base64ToBlob expects
|
||||
// base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..."
|
||||
// Try to detect image type from base64 or default to jpeg
|
||||
let mimeType = "image/jpeg"; // default
|
||||
if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) {
|
||||
// JPEG or PNG
|
||||
mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
|
||||
}
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
|
||||
// Use INSERT OR REPLACE to handle existing records
|
||||
await platformService.dbExec(
|
||||
"INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)",
|
||||
[SHARED_PHOTO_BASE64_KEY, dataUrl],
|
||||
);
|
||||
|
||||
// Delete the temp file
|
||||
try {
|
||||
await Filesystem.deleteFile({
|
||||
path: tempFilePath,
|
||||
directory: Directory.Documents,
|
||||
});
|
||||
} catch (deleteError) {
|
||||
logger.error("[Main] Failed to delete temp file:", deleteError);
|
||||
}
|
||||
|
||||
logger.info(`[Main] Stored shared image: ${fileName}`);
|
||||
return { success: true, fileName };
|
||||
}
|
||||
}
|
||||
} catch (fileError: unknown) {
|
||||
// File doesn't exist or can't be read - that's okay, try plugin method
|
||||
logger.debug(
|
||||
"[Main] Temp file not found or unreadable (this is normal if plugin works)",
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: Plugin registration issue - ShareImage plugin is not being auto-discovered
|
||||
// This is a known issue that needs to be resolved. For now, we use the temp file workaround above.
|
||||
|
||||
// Try multiple methods to call the plugin (fallback if temp file method fails)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const plugins = (capacitor as any).Plugins;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const shareImagePlugin = (plugins as any)?.ShareImage;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let result: any = null;
|
||||
|
||||
if (
|
||||
shareImagePlugin &&
|
||||
typeof shareImagePlugin.getSharedImageData === "function"
|
||||
) {
|
||||
logger.debug("[Main] Using direct plugin method");
|
||||
try {
|
||||
result = await shareImagePlugin.getSharedImageData();
|
||||
} catch (pluginError) {
|
||||
logger.error("[Main] Plugin call failed:", pluginError);
|
||||
return { success: false };
|
||||
}
|
||||
} else {
|
||||
// Method 2: Use Capacitor's execute method
|
||||
logger.debug(
|
||||
"[Main] Plugin not found directly, trying Capacitor.execute",
|
||||
);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bridge = (capacitor as any).getBridge?.();
|
||||
if (bridge) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result = await bridge.execute({
|
||||
pluginId: "ShareImage",
|
||||
methodName: "getSharedImageData",
|
||||
options: {},
|
||||
});
|
||||
} else {
|
||||
// Method 3: Try execute on Plugins object
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((plugins as any)?.execute) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result = await (plugins as any).execute(
|
||||
"ShareImage",
|
||||
"getSharedImageData",
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (executeError) {
|
||||
logger.error("[Main] Execute method failed:", executeError);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (!result || !result.success || !result.data) {
|
||||
logger.debug("[Main] No shared image data found in result");
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const { base64, fileName } = result.data;
|
||||
|
||||
if (!base64) {
|
||||
logger.debug("[Main] Shared image data missing base64");
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
logger.info("[Main] Native shared image found, storing in temp DB");
|
||||
|
||||
// Store in temp database (similar to web flow)
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
// $insertEntity is available via PlatformServiceMixin
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (platformService as any).$insertEntity(
|
||||
"temp",
|
||||
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64 },
|
||||
["id", "blobB64"],
|
||||
);
|
||||
|
||||
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
|
||||
return { success: true, fileName: fileName || "shared-image.jpg" };
|
||||
} catch (error) {
|
||||
logger.error("[Main] Error checking for native shared image:", error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeepLink = async (data: { url: string }) => {
|
||||
const { url } = data;
|
||||
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
||||
|
||||
try {
|
||||
// Check if this is a shared-photo deep link from native share
|
||||
const isSharedPhotoLink = url.includes("timesafari://shared-photo");
|
||||
|
||||
if (
|
||||
isSharedPhotoLink &&
|
||||
Capacitor.isNativePlatform() &&
|
||||
Capacitor.getPlatform() === "ios"
|
||||
) {
|
||||
logger.debug(
|
||||
"[Main] 📸 Shared photo deep link detected, checking for native shared image",
|
||||
);
|
||||
|
||||
// Try to get shared image from App Group and store in temp database
|
||||
try {
|
||||
const imageResult = await checkAndStoreNativeSharedImage();
|
||||
|
||||
if (imageResult.success) {
|
||||
logger.info("[Main] ✅ Native shared image stored in temp database");
|
||||
|
||||
// Add fileName to the URL as a query parameter if we have it
|
||||
if (imageResult.fileName) {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.searchParams.set("fileName", imageResult.fileName);
|
||||
const modifiedUrl = urlObj.toString();
|
||||
data.url = modifiedUrl;
|
||||
logger.debug(`[Main] Added fileName to URL: ${modifiedUrl}`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
"[Main] ℹ️ No native shared image found (may be from web or already processed)",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[Main] Error processing native shared image:", error);
|
||||
// Continue with normal deep link processing even if native check fails
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for router to be ready
|
||||
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
|
||||
await router.isReady();
|
||||
|
||||
@@ -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;");
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -898,7 +898,13 @@ export default class HomeView extends Vue {
|
||||
this.starredPlanHandleIds,
|
||||
this.lastAckedStarredPlanChangesJwtId,
|
||||
);
|
||||
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
|
||||
// filter out any data elements where there is no wrappedClaimBefore
|
||||
const filteredNewStarredProjectChanges =
|
||||
starredProjectChanges.data.filter(
|
||||
(change) => change.wrappedClaimBefore !== undefined,
|
||||
);
|
||||
this.numNewStarredProjectChanges =
|
||||
filteredNewStarredProjectChanges.length;
|
||||
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
|
||||
} catch (error) {
|
||||
// Don't show errors for starred project changes as it's a secondary feature
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -186,16 +186,59 @@
|
||||
<div>
|
||||
<label
|
||||
for="projectLink"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Project Link</label
|
||||
>
|
||||
<input
|
||||
id="projectLink"
|
||||
v-model="newOrUpdatedMeetingInputs.projectLink"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Project ID"
|
||||
/>
|
||||
<div class="w-full flex items-stretch">
|
||||
<div
|
||||
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
|
||||
@click="openProjectLinkDialog"
|
||||
>
|
||||
<div>
|
||||
<ProjectIcon
|
||||
v-if="selectedProject"
|
||||
:entity-id="selectedProject.handleId"
|
||||
:icon-size="30"
|
||||
:image-url="selectedProject.image"
|
||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="folder-open"
|
||||
class="text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<div
|
||||
:class="{
|
||||
'text-sm font-semibold': selectedProject,
|
||||
'text-slate-400': !selectedProject,
|
||||
}"
|
||||
class="truncate"
|
||||
>
|
||||
{{
|
||||
selectedProject
|
||||
? selectedProject.name || "Unnamed Project"
|
||||
: "Select Project…"
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedProject"
|
||||
class="text-xs text-slate-500 truncate"
|
||||
>
|
||||
<font-awesome icon="user" class="text-slate-400" />
|
||||
{{ selectedProjectIssuerName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="selectedProject"
|
||||
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
||||
@click="unsetProjectLink"
|
||||
>
|
||||
<font-awesome icon="trash-can" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -224,6 +267,17 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<MeetingProjectDialog
|
||||
ref="meetingProjectDialog"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:notify="$notify"
|
||||
@assign="handleProjectLinkAssigned"
|
||||
@open="handleDialogOpen"
|
||||
@close="handleDialogClose"
|
||||
/>
|
||||
|
||||
<!-- Members Section -->
|
||||
<div
|
||||
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
||||
@@ -254,6 +308,7 @@
|
||||
</ul>
|
||||
|
||||
<MembersList
|
||||
ref="membersList"
|
||||
:password="currentMeeting.password || ''"
|
||||
:show-organizer-tools="true"
|
||||
class="mt-4"
|
||||
@@ -292,10 +347,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import MembersList from "../components/MembersList.vue";
|
||||
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import {
|
||||
errorStringForLog,
|
||||
getHeaders,
|
||||
serverMessageForUser,
|
||||
didInfo,
|
||||
} from "../libs/endorserServer";
|
||||
import { encryptMessage } from "../libs/crypto";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
@@ -309,6 +367,8 @@ import {
|
||||
NOTIFY_MEETING_DELETED,
|
||||
NOTIFY_MEETING_LINK_COPIED,
|
||||
} from "@/constants/notifications";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
interface ServerMeeting {
|
||||
groupId: number; // from the server
|
||||
name: string; // to & from the server
|
||||
@@ -331,6 +391,8 @@ interface MeetingSetupInputs {
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
MembersList,
|
||||
MeetingProjectDialog,
|
||||
ProjectIcon,
|
||||
},
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
@@ -354,6 +416,9 @@ export default class OnboardMeetingView extends Vue {
|
||||
isRegistered = false;
|
||||
showDeleteConfirm = false;
|
||||
fullName = "";
|
||||
allContacts: Contact[] = [];
|
||||
allMyDids: string[] = [];
|
||||
selectedProjectData: PlanData | null = null;
|
||||
get minDateTime() {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
||||
@@ -370,7 +435,17 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.fullName = settings?.firstName || "";
|
||||
this.isRegistered = !!settings?.isRegistered;
|
||||
|
||||
// Load contacts and DIDs for dialog
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.allContacts = await (this as any).$contactsByDateAdded();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.allMyDids = await (this as any).$getAllAccountDids();
|
||||
|
||||
await this.fetchCurrentMeeting();
|
||||
|
||||
// Ensure selected project is loaded if projectLink exists
|
||||
await this.ensureSelectedProjectLoaded();
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
@@ -442,6 +517,54 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the selected project is loaded if projectLink exists
|
||||
*/
|
||||
async ensureSelectedProjectLoaded(): Promise<void> {
|
||||
const projectLink =
|
||||
this.currentMeeting?.projectLink ||
|
||||
this.newOrUpdatedMeetingInputs?.projectLink;
|
||||
|
||||
if (!projectLink) {
|
||||
this.selectedProjectData = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fetchProjectByHandleId(projectLink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single project by handleId
|
||||
* @param handleId - The project handleId to fetch
|
||||
*/
|
||||
async fetchProjectByHandleId(handleId: string): Promise<void> {
|
||||
try {
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
|
||||
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
|
||||
const project = resp.data.data[0];
|
||||
this.selectedProjectData = {
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
image: project.image,
|
||||
handleId: project.handleId,
|
||||
issuerDid: project.issuerDid,
|
||||
rowId: project.rowId,
|
||||
};
|
||||
} else {
|
||||
this.selectedProjectData = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.$logAndConsole(
|
||||
"Error fetching project by handleId: " + errorStringForLog(error),
|
||||
true,
|
||||
);
|
||||
this.selectedProjectData = null;
|
||||
}
|
||||
}
|
||||
|
||||
async createMeeting() {
|
||||
this.isLoading = true;
|
||||
|
||||
@@ -576,7 +699,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
startEditing() {
|
||||
async startEditing() {
|
||||
// Populate form with existing meeting data
|
||||
if (this.currentMeeting) {
|
||||
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||
@@ -587,6 +710,10 @@ export default class OnboardMeetingView extends Vue {
|
||||
password: this.currentMeeting.password || "",
|
||||
projectLink: this.currentMeeting.projectLink || "",
|
||||
};
|
||||
// Ensure selected project is loaded if projectLink exists
|
||||
if (this.currentMeeting.projectLink) {
|
||||
await this.ensureSelectedProjectLoaded();
|
||||
}
|
||||
} else {
|
||||
this.$logError(
|
||||
"There is no current meeting to edit. We should never get here.",
|
||||
@@ -594,9 +721,15 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
cancelEditing() {
|
||||
async cancelEditing() {
|
||||
// Reset form data
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
// Restore selected project from currentMeeting if it exists
|
||||
if (this.currentMeeting?.projectLink) {
|
||||
await this.ensureSelectedProjectLoaded();
|
||||
} else {
|
||||
this.selectedProjectData = null;
|
||||
}
|
||||
}
|
||||
|
||||
async updateMeeting() {
|
||||
@@ -710,5 +843,78 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.notify.error("Failed to copy meeting link to clipboard.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for selected project
|
||||
* Returns the separately stored selected project data
|
||||
*/
|
||||
get selectedProject(): PlanData | null {
|
||||
return this.selectedProjectData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed property for selected project issuer display name
|
||||
* Uses didInfo to format the issuer name similar to ProjectCard
|
||||
*/
|
||||
get selectedProjectIssuerName(): string {
|
||||
if (!this.selectedProject) {
|
||||
return "";
|
||||
}
|
||||
return didInfo(
|
||||
this.selectedProject.issuerDid,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the project link selection dialog
|
||||
*/
|
||||
openProjectLinkDialog(): void {
|
||||
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle project assignment from dialog
|
||||
*/
|
||||
handleProjectLinkAssigned(project: PlanData): void {
|
||||
// Store the selected project directly
|
||||
this.selectedProjectData = project;
|
||||
|
||||
if (this.newOrUpdatedMeetingInputs) {
|
||||
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unset the project link and revert to initial state
|
||||
*/
|
||||
unsetProjectLink(): void {
|
||||
this.selectedProjectData = null;
|
||||
if (this.newOrUpdatedMeetingInputs) {
|
||||
this.newOrUpdatedMeetingInputs.projectLink = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dialog open event - stop auto-refresh in MembersList
|
||||
*/
|
||||
handleDialogOpen(): void {
|
||||
const membersList = this.$refs.membersList as MembersList;
|
||||
if (membersList) {
|
||||
membersList.stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dialog close event - start auto-refresh in MembersList
|
||||
*/
|
||||
handleDialogClose(): void {
|
||||
const membersList = this.$refs.membersList as MembersList;
|
||||
if (membersList) {
|
||||
membersList.startAutoRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -54,6 +54,108 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Nearest Neighbors Section -->
|
||||
<div
|
||||
v-if="
|
||||
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
|
||||
profile.issuerDid !== neighbors?.[0]?.did // and they're not directly connected (since there's no in-between)
|
||||
"
|
||||
class="mt-6"
|
||||
>
|
||||
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
|
||||
|
||||
<div v-if="loadingNeighbors">
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin-pulse text-2xl text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="neighborsError"
|
||||
class="bg-red-50 border border-red-300 rounded-md p-4"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<font-awesome
|
||||
icon="exclamation-triangle"
|
||||
class="text-red-500 mt-0.5"
|
||||
/>
|
||||
<p class="text-red-700">{{ neighborsError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="neighbor in neighbors"
|
||||
:key="neighbor.did"
|
||||
class="bg-slate-50 border border-slate-300 rounded-md"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 p-3">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<button
|
||||
title="Copy profile link and expand"
|
||||
class="text-blue-600 flex-shrink-0"
|
||||
@click="onNeighborExpandClick(neighbor.did)"
|
||||
>
|
||||
<font-awesome
|
||||
:icon="
|
||||
expandedNeighborDid === neighbor.did
|
||||
? 'chevron-down'
|
||||
: 'chevron-right'
|
||||
"
|
||||
class="text-sm"
|
||||
/>
|
||||
{{ getNeighborDisplayName(neighbor.did) }}
|
||||
</button>
|
||||
<span :class="getRelationBadgeClass(neighbor.relation)">
|
||||
{{ getRelationLabel(neighbor.relation) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expandedNeighborDid === neighbor.did"
|
||||
class="border-t border-slate-300 p-3 bg-white"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'did', params: { did: neighbor.did } }"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium underline"
|
||||
>
|
||||
Go to contact info
|
||||
</router-link>
|
||||
and send them the link in your clipboard and ask for an
|
||||
introduction to this person.
|
||||
<div
|
||||
v-if="
|
||||
getNeighborDisplayName(neighbor.did) === '' ||
|
||||
neighborIsNotInContacts(neighbor.did)
|
||||
"
|
||||
class="flex flex-col gap-1 mt-2"
|
||||
>
|
||||
<p class="text-xs text-slate-600">
|
||||
This person is connected to you, but they are not in this
|
||||
device's contacts. Copy this DID link and check on another
|
||||
device or check with different people.
|
||||
</p>
|
||||
<span class="flex items-center gap-1 min-w-0">
|
||||
<span class="text-xs truncate text-slate-600 min-w-0">
|
||||
{{ neighbor.did }}
|
||||
</span>
|
||||
<button
|
||||
title="Copy DID Link"
|
||||
class="text-blue-600 hover:text-blue-800 underline cursor-pointer flex-shrink-0"
|
||||
@click.prevent="onCopyDidClick(neighbor.did)"
|
||||
>
|
||||
<font-awesome icon="copy" class="text-sm" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map for first coordinates -->
|
||||
<div v-if="hasFirstLocation" class="mt-4">
|
||||
<h2 class="text-lg font-semibold">Location</h2>
|
||||
@@ -159,7 +261,11 @@ export default class UserProfileView extends Vue {
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
expandedNeighborDid: string | null = null;
|
||||
isLoading = true;
|
||||
loadingNeighbors = false;
|
||||
neighbors: Array<{ did: string; relation: string }> = [];
|
||||
neighborsError = "";
|
||||
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||
profile: UserProfile | null = null;
|
||||
|
||||
@@ -183,8 +289,8 @@ export default class UserProfileView extends Vue {
|
||||
*/
|
||||
async mounted() {
|
||||
await this.initializeSettings();
|
||||
await this.loadContacts();
|
||||
await this.loadProfile();
|
||||
await this.loadNeighbors();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,12 +305,7 @@ export default class UserProfileView extends Vue {
|
||||
this.activeDid = activeIdentity.activeDid || "";
|
||||
|
||||
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all contacts from database
|
||||
*/
|
||||
private async loadContacts() {
|
||||
this.allContacts = await this.$getAllContacts();
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
}
|
||||
@@ -249,23 +350,100 @@ export default class UserProfileView extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies profile link to clipboard
|
||||
* Loads nearest neighbors from partner API
|
||||
*
|
||||
* Creates a deep link to the profile and copies it to the clipboard
|
||||
* Shows success notification when completed
|
||||
* Fetches network connections for the profile and displays them
|
||||
* with appropriate relation labels
|
||||
*/
|
||||
async loadNeighbors() {
|
||||
const profileId: string = this.$route.params.id as string;
|
||||
if (!profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadingNeighbors = true;
|
||||
this.neighborsError = "";
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.partnerApiServer}/api/partner/userProfileNearestNeighbors/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await getHeaders(this.activeDid),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
const result = await response.json();
|
||||
this.neighbors = result.data;
|
||||
this.neighborsError = "";
|
||||
} else {
|
||||
logger.warn("Failed to load neighbors:", response.status);
|
||||
this.neighbors = [];
|
||||
this.neighborsError = "Failed to load network connections.";
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error loading neighbors:", error);
|
||||
this.neighbors = [];
|
||||
this.neighborsError =
|
||||
"An error occurred while loading network connections.";
|
||||
} finally {
|
||||
this.loadingNeighbors = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a deep link to the profile to the clipboard
|
||||
*/
|
||||
async onCopyLinkClick() {
|
||||
// Use production URL for sharing to avoid localhost issues in development
|
||||
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
||||
try {
|
||||
await copyToClipboard(deepLink);
|
||||
this.notify.copied("profile link", TIMEOUTS.STANDARD);
|
||||
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
|
||||
} catch (error) {
|
||||
this.$logAndConsole(`Error copying profile link: ${error}`, true);
|
||||
this.notify.error("Failed to copy profile link.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a deep link to the provided DID to the clipboard
|
||||
*/
|
||||
async onCopyDidClick(did: string) {
|
||||
const deepLink = `${APP_SERVER}/deep-link/did/${encodeURIComponent(did)}`;
|
||||
try {
|
||||
await copyToClipboard(deepLink);
|
||||
this.notify.copied("DID link", TIMEOUTS.STANDARD);
|
||||
} catch (error) {
|
||||
this.$logAndConsole(`Error copying DID link: ${error}`, true);
|
||||
this.notify.error("Failed to copy DID link.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking the expand button next to a neighbor's name
|
||||
* Copies the profile link to clipboard and toggles the expanded section
|
||||
*/
|
||||
async onNeighborExpandClick(did: string) {
|
||||
if (this.expandedNeighborDid === did) {
|
||||
this.expandedNeighborDid = null;
|
||||
// don't copy the link
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy the profile link
|
||||
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
||||
try {
|
||||
await copyToClipboard(deepLink);
|
||||
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
|
||||
} catch (error) {
|
||||
this.$logAndConsole(`Error copying profile link: ${error}`, true);
|
||||
this.notify.error("Failed to copy profile link.");
|
||||
}
|
||||
|
||||
// Toggle the expanded section
|
||||
this.expandedNeighborDid = did;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed properties for template logic streamlining
|
||||
*/
|
||||
@@ -330,5 +508,64 @@ export default class UserProfileView extends Vue {
|
||||
get tileLayerUrl() {
|
||||
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets display name for a neighbor's DID
|
||||
* Uses didInfo utility to show contact name if available, otherwise DID
|
||||
* @param did - The DID to get display name for
|
||||
* @returns Formatted display name
|
||||
*/
|
||||
getNeighborDisplayName(did: string): string {
|
||||
return this.didInfo(did, this.activeDid, this.allMyDids, this.allContacts);
|
||||
}
|
||||
|
||||
neighborIsNotInContacts(did: string) {
|
||||
return !this.allContacts.some((contact) => contact.did === did);
|
||||
}
|
||||
|
||||
noNeighborsAreInContacts() {
|
||||
return this.neighbors.every(
|
||||
(neighbor) =>
|
||||
!this.allContacts.some((contact) => contact.did === neighbor.did),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets human-readable label for relation type
|
||||
* @param relation - The relation type from API
|
||||
* @returns Display label for the relation
|
||||
*/
|
||||
getRelationLabel(relation: string): string {
|
||||
switch (relation) {
|
||||
case "REGISTERED_BY_YOU":
|
||||
return "Registered by You";
|
||||
case "REGISTERED_YOU":
|
||||
return "Registered You";
|
||||
case "TARGET":
|
||||
return "Yourself";
|
||||
default:
|
||||
return relation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets CSS classes for relation badge styling
|
||||
* @param relation - The relation type from API
|
||||
* @returns CSS class string for badge
|
||||
*/
|
||||
getRelationBadgeClass(relation: string): string {
|
||||
const baseClasses =
|
||||
"text-xs font-semibold px-2 py-1 rounded whitespace-nowrap";
|
||||
switch (relation) {
|
||||
case "REGISTERED_BY_YOU":
|
||||
return `${baseClasses} bg-blue-100 text-blue-700`;
|
||||
case "REGISTERED_YOU":
|
||||
return `${baseClasses} bg-green-100 text-green-700`;
|
||||
case "TARGET":
|
||||
return `${baseClasses} bg-purple-100 text-purple-700`;
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-700`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user