Compare commits
57 Commits
bulk-membe
...
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 | ||
|
|
4e3e293495 | ||
|
|
65533c15d2 | ||
|
|
2530bc0ec2 | ||
| 5050156beb | |||
| b1fa6ac458 | |||
| 9ff24f8258 | |||
|
|
9a3409c29f | ||
| d265a9f78c | |||
| f848de15f1 | |||
| ebaf2dedf0 | |||
|
|
749204f96b | ||
|
|
a142737771 | ||
| 1053bb6e4c | |||
| 88f46787e5 | |||
|
|
d9230d0be8 | ||
|
|
38f301f053 | ||
| e42552c67a | |||
| 0e3c6cb314 | |||
| 232b787b37 | |||
|
|
d32cca4f53 | ||
|
|
4004d9fe52 | ||
|
|
2f99d0b416 | ||
|
|
9c3002f9c7 | ||
|
|
82fd7cddf7 | ||
|
|
10f2920e11 | ||
|
|
75c89b471c | ||
|
|
a804877a08 | ||
|
|
f7441f39e7 | ||
|
|
e647af0777 |
63
BUILDING.md
63
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:
|
- ... and you may have to fix these, especially with pkgx:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gem_path=$(which gem)
|
gem_path=$(which gem)
|
||||||
shortened_path="${gem_path:h:h}"
|
shortened_path="${gem_path:h:h}"
|
||||||
export GEM_HOME=$shortened_path
|
export GEM_HOME=$shortened_path
|
||||||
export GEM_PATH=$shortened_path
|
export GEM_PATH=$shortened_path
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 1. Bump the version in package.json, then here
|
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ios/App && xcrun agvtool new-version 40 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.7;/g" App.xcodeproj/project.pbxproj && cd -
|
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.
|
# Unfortunately this edits Info.plist directly.
|
||||||
#xcrun agvtool new-marketing-version 0.4.5
|
#xcrun agvtool new-marketing-version 0.4.5
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
|
|
||||||
Here's prod. Also available: test, dev
|
Here's prod. Also available: test, dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:ios:prod
|
npm run build:ios:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
3.1. Use Xcode to build and run on simulator or device.
|
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.
|
- 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.
|
- You'll probably have to "Manage" something about encryption, disallowed in France.
|
||||||
- Then "Save" and "Add to Review" and "Resubmit to App Review".
|
- 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
|
### 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
|
# Standard build and open Android Studio
|
||||||
./scripts/build-android.sh
|
./scripts/build-android.sh
|
||||||
|
|
||||||
# Build with specific version numbers
|
# Build with specific version numbers -- doesn't change source files
|
||||||
./scripts/build-android.sh --version 1.0.3 --build-number 35
|
#./scripts/build-android.sh --version 1.1.3 --build-number 48
|
||||||
|
|
||||||
# Build without opening Android Studio (for CI/CD)
|
# Build without opening Android Studio (for CI/CD)
|
||||||
./scripts/build-android.sh --no-studio
|
./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
|
#### 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
|
```bash
|
||||||
perl -p -i -e 's/versionCode .*/versionCode 40/g' android/app/build.gradle
|
perl -p -i -e 's/versionCode .*/versionCode 48/g' android/app/build.gradle
|
||||||
perl -p -i -e 's/versionName .*/versionName "1.0.7"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.1.3"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
|
|
||||||
Here's prod. Also available: test, dev
|
Here's prod. Also available: test, dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:android:prod
|
npm run build:android:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 3. Open the project in Android Studio
|
##### 3. Open the project in Android Studio
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx cap open android
|
npx cap open android
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 4. Use Android Studio to build and run on emulator or device
|
##### 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
|
- 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.
|
those changes or your (closed) testers won't see it.
|
||||||
|
|
||||||
|
- When finished, bump package.json version
|
||||||
|
|
||||||
### Capacitor Operations
|
### Capacitor Operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.3] - 2025.11.19
|
||||||
|
### Changed
|
||||||
|
- Project selection in dialogs now reaches out to server when filtering
|
||||||
|
- Project selection during onboarding meeting is a search (not an input box)
|
||||||
|
- Improve the switching of agent when agent edits a project
|
||||||
|
### Fixed
|
||||||
|
- Reassignment of "you" as recipient when changing giver project
|
||||||
|
- Bad counts for project-change notification on front page
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.2] - 2025.11.06
|
||||||
|
### Fixed
|
||||||
|
- Bad page when user follows prompt to backup seed
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.1] - 2025.11.03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Meeting onboarding via prompts
|
||||||
|
- Emojis on gift feed
|
||||||
|
- Starred projects with notification
|
||||||
|
|
||||||
|
|
||||||
## [1.0.7] - 2025.08.18
|
## [1.0.7] - 2025.08.18
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ android {
|
|||||||
applicationId "app.timesafari.app"
|
applicationId "app.timesafari.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 41
|
versionCode 48
|
||||||
versionName "1.0.8"
|
versionName "1.1.3"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// 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;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -15,8 +15,35 @@
|
|||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
|
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 */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; 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; };
|
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>"; };
|
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>"; };
|
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 */
|
/* 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 */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
504EC3011FED79650016851F /* Frameworks */ = {
|
504EC3011FED79650016851F /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
@@ -41,6 +86,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
C86585D22ED456DE00824752 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -56,6 +108,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
504EC3061FED79650016851F /* App */,
|
504EC3061FED79650016851F /* App */,
|
||||||
|
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
|
||||||
504EC3051FED79650016851F /* Products */,
|
504EC3051FED79650016851F /* Products */,
|
||||||
BA325FFCDCE8D334E5C7AEBE /* Pods */,
|
BA325FFCDCE8D334E5C7AEBE /* Pods */,
|
||||||
4B546315E668C7A13939F417 /* Frameworks */,
|
4B546315E668C7A13939F417 /* Frameworks */,
|
||||||
@@ -66,6 +119,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
504EC3041FED79650016851F /* App.app */,
|
504EC3041FED79650016851F /* App.app */,
|
||||||
|
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -73,6 +127,9 @@
|
|||||||
504EC3061FED79650016851F /* App */ = {
|
504EC3061FED79650016851F /* App */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */,
|
||||||
|
C86585E72ED45A3D00824752 /* ShareImageBridge.swift */,
|
||||||
|
C86585E52ED4577F00824752 /* App.entitlements */,
|
||||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||||
@@ -108,16 +165,40 @@
|
|||||||
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
|
||||||
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
|
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
|
||||||
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
|
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
|
||||||
|
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
C86585DE2ED456DE00824752 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = App;
|
name = App;
|
||||||
productName = App;
|
productName = App;
|
||||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -125,7 +206,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 920;
|
LastSwiftUpdateCheck = 2610;
|
||||||
LastUpgradeCheck = 1630;
|
LastUpgradeCheck = 1630;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
@@ -133,6 +214,9 @@
|
|||||||
LastSwiftMigration = 1100;
|
LastSwiftMigration = 1100;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
|
C86585D42ED456DE00824752 = {
|
||||||
|
CreatedOnToolsVersion = 26.1.1;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||||
@@ -149,6 +233,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
504EC3031FED79650016851F /* App */,
|
504EC3031FED79650016851F /* App */,
|
||||||
|
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -167,6 +252,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
C86585D32ED456DE00824752 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
@@ -253,12 +345,29 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */,
|
||||||
|
C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */,
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
C86585D12ED456DE00824752 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
C86585DE2ED456DE00824752 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
|
||||||
|
targetProxy = C86585DD2ED456DE00824752 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||||
isa = PBXVariantGroup;
|
isa = PBXVariantGroup;
|
||||||
@@ -402,8 +511,9 @@
|
|||||||
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
|
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 41;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -413,7 +523,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.8;
|
MARKETING_VERSION = 1.1.3;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -429,8 +539,9 @@
|
|||||||
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
|
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 41;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -440,7 +551,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.8;
|
MARKETING_VERSION = 1.1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -450,6 +561,80 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -471,6 +656,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
C86585E12ED456DE00824752 /* Debug */,
|
||||||
|
C86585E22ED456DE00824752 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
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 {
|
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,
|
// 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
|
// 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)
|
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
|
// tracking app url opens, make sure to keep this call
|
||||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
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",
|
"name": "timesafari",
|
||||||
"version": "1.1.1-beta",
|
"version": "1.1.4-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.1-beta",
|
"version": "1.1.4-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/electron": "^5.0.1",
|
"@capacitor-community/electron": "^5.0.1",
|
||||||
"@capacitor-community/sqlite": "6.0.2",
|
"@capacitor-community/sqlite": "6.0.2",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@ethersproject/hdnode": "^5.7.0",
|
"@ethersproject/hdnode": "^5.7.0",
|
||||||
"@ethersproject/wallet": "^5.8.0",
|
"@ethersproject/wallet": "^5.8.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
"@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-regular-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||||
@@ -6789,6 +6790,25 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/@fortawesome/free-regular-svg-icons": {
|
||||||
"version": "6.7.2",
|
"version": "6.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.1-beta",
|
"version": "1.1.4-beta",
|
||||||
"description": "Time Safari Application",
|
"description": "Time Safari Application",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Time Safari Team"
|
"name": "Time Safari Team"
|
||||||
@@ -156,6 +156,7 @@
|
|||||||
"@ethersproject/hdnode": "^5.7.0",
|
"@ethersproject/hdnode": "^5.7.0",
|
||||||
"@ethersproject/wallet": "^5.8.0",
|
"@ethersproject/wallet": "^5.8.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
"@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-regular-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||||
|
|||||||
@@ -436,7 +436,21 @@ fi
|
|||||||
log_info "Cleaning dist directory..."
|
log_info "Cleaning dist directory..."
|
||||||
clean_build_artifacts "dist"
|
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
|
if [ "$BUILD_MODE" = "development" ]; then
|
||||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||||
elif [ "$BUILD_MODE" = "test" ]; then
|
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
|
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 5: Clean Gradle build
|
# Step 6: Clean Gradle build
|
||||||
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
|
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
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||||
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 7: Sync with Capacitor
|
# Step 8: Sync with Capacitor
|
||||||
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
|
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
|
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_APK" = true ]; then
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
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
|
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 10: Auto-run app if requested
|
# Step 11: Auto-run app if requested
|
||||||
if [ "$AUTO_RUN" = true ]; then
|
if [ "$AUTO_RUN" = true ]; then
|
||||||
log_step "Auto-running Android app..."
|
log_step "Auto-running Android app..."
|
||||||
safe_execute "Launching app" "npx cap run android" || {
|
safe_execute "Launching app" "npx cap run android" || {
|
||||||
@@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
|
|||||||
log_success "Android app launched successfully!"
|
log_success "Android app launched successfully!"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 11: Open Android Studio if requested
|
# Step 12: Open Android Studio if requested
|
||||||
if [ "$OPEN_STUDIO" = true ]; then
|
if [ "$OPEN_STUDIO" = true ]; then
|
||||||
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
|
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -381,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
|
|||||||
log_info "Cleaning dist directory..."
|
log_info "Cleaning dist directory..."
|
||||||
clean_build_artifacts "dist"
|
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
|
if [ "$BUILD_MODE" = "development" ]; then
|
||||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||||
elif [ "$BUILD_MODE" = "test" ]; then
|
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
|
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 5: Sync with Capacitor
|
# Step 6: Install CocoaPods dependencies (with Xcode 26 workaround)
|
||||||
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
# ===================================================================
|
||||||
|
# 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
|
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
|
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
|
if [ "$BUILD_IPA" = true ]; then
|
||||||
log_info "Building IPA package..."
|
log_info "Building IPA package..."
|
||||||
cd ios/App
|
cd ios/App
|
||||||
@@ -426,12 +573,12 @@ if [ "$BUILD_APP" = true ]; then
|
|||||||
log_success "App bundle built successfully"
|
log_success "App bundle built successfully"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 9: Auto-run app if requested
|
# Step 10: Auto-run app if requested
|
||||||
if [ "$AUTO_RUN" = true ]; then
|
if [ "$AUTO_RUN" = true ]; then
|
||||||
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
|
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 10: Open Xcode if requested
|
# Step 11: Open Xcode if requested
|
||||||
if [ "$OPEN_STUDIO" = true ]; then
|
if [ "$OPEN_STUDIO" = true ]; then
|
||||||
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
|
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
@apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[90%] overflow-y-auto;
|
@apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown content styling to restore list elements */
|
/* Markdown content styling to restore list elements */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="sectionDataExport" :class="containerClasses">
|
<div id="sectionDataExport" :class="containerClasses">
|
||||||
<div :class="titleClasses">Data Export</div>
|
<div :class="titleClasses">Data Management</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="activeDid"
|
v-if="activeDid"
|
||||||
:to="{ name: 'seed-backup' }"
|
:to="{ name: 'seed-backup' }"
|
||||||
@@ -30,7 +30,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
|
|||||||
:class="exportButtonClasses"
|
:class="exportButtonClasses"
|
||||||
@click="exportDatabase()"
|
@click="exportDatabase()"
|
||||||
>
|
>
|
||||||
{{ isExporting ? "Exporting..." : "Download Contacts" }}
|
{{ isExporting ? "Exporting..." : "Export Contacts" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -55,11 +55,54 @@ messages * - Conditional UI based on platform capabilities * * @component *
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||||
|
import { Router } from "vue-router";
|
||||||
import * as R from "ramda";
|
import * as R from "ramda";
|
||||||
|
|
||||||
import { AppString, NotificationIface } from "../constants/app";
|
import { AppString, NotificationIface } from "../constants/app";
|
||||||
@@ -67,8 +110,10 @@ import { Contact } from "../db/tables/contacts";
|
|||||||
|
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { contactsToExportJson } from "../libs/util";
|
import { contactsToExportJson } from "../libs/util";
|
||||||
import { createNotifyHelpers } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||||
|
import { ImportContent } from "@/interfaces/accountView";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vue-component
|
* @vue-component
|
||||||
@@ -91,6 +136,12 @@ export default class DataExportSection extends Vue {
|
|||||||
*/
|
*/
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router instance injected by Vue
|
||||||
|
* Used for navigation
|
||||||
|
*/
|
||||||
|
$router!: Router;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Active DID (Decentralized Identifier) of the user
|
* Active DID (Decentralized Identifier) of the user
|
||||||
* Controls visibility of seed backup option
|
* Controls visibility of seed backup option
|
||||||
@@ -110,6 +161,12 @@ export default class DataExportSection extends Vue {
|
|||||||
*/
|
*/
|
||||||
showRedNotificationDot = false;
|
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
|
* Notification helper for consistent notification patterns
|
||||||
* Created as a getter to ensure $notify is available when called
|
* 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)
|
// 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);
|
const exContact: Contact = R.omit(["contactMethods"], contact);
|
||||||
// now add contactMethods as a true array of ContactMethod objects
|
// now add contactMethods as a true array of ContactMethod objects
|
||||||
exContact.contactMethods = contact.contactMethods
|
// $contacts() returns normalized contacts where contactMethods is already an array,
|
||||||
? typeof contact.contactMethods === "string" &&
|
// but we handle both array and string cases for robustness
|
||||||
contact.contactMethods.trim() !== ""
|
if (contact.contactMethods) {
|
||||||
? JSON.parse(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;
|
return exContact;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,5 +323,58 @@ export default class DataExportSection extends Vue {
|
|||||||
this.showRedNotificationDot = false;
|
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>
|
</script>
|
||||||
|
|||||||
@@ -2,12 +2,55 @@
|
|||||||
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
|
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
|
||||||
projects, and special entities with selection. * * @author Matthew Raymer */
|
projects, and special entities with selection. * * @author Matthew Raymer */
|
||||||
<template>
|
<template>
|
||||||
<ul :class="gridClasses">
|
<!-- Quick Search -->
|
||||||
|
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
|
||||||
|
<input
|
||||||
|
v-model="searchTerm"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search…"
|
||||||
|
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
|
||||||
|
@input="handleSearchInput"
|
||||||
|
@keydown.enter="performSearch"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-show="isSearching && searchTerm"
|
||||||
|
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
icon="spinner"
|
||||||
|
class="fa-spin-pulse leading-[1.1]"
|
||||||
|
></font-awesome>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:disabled="!searchTerm"
|
||||||
|
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
|
||||||
|
@click="clearSearch"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
:icon="searchTerm ? 'times' : 'magnifying-glass'"
|
||||||
|
class="fa-fw"
|
||||||
|
></font-awesome>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
|
||||||
|
class="mb-4 text-sm italic text-slate-500 text-center"
|
||||||
|
>
|
||||||
|
“{{ searchTerm }}” doesn't match any
|
||||||
|
{{ entityType === "people" ? "people" : "projects" }}. Try a different
|
||||||
|
search.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
ref="scrollContainer"
|
||||||
|
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
|
||||||
|
>
|
||||||
<!-- Special entities (You, Unnamed) for people grids -->
|
<!-- Special entities (You, Unnamed) for people grids -->
|
||||||
<template v-if="entityType === 'people'">
|
<template v-if="entityType === 'people'">
|
||||||
<!-- "You" entity -->
|
<!-- "You" entity -->
|
||||||
<SpecialEntityCard
|
<SpecialEntityCard
|
||||||
v-if="showYouEntity"
|
v-if="showYouEntity && !searchTerm.trim()"
|
||||||
entity-type="you"
|
entity-type="you"
|
||||||
label="You"
|
label="You"
|
||||||
icon="hand"
|
icon="hand"
|
||||||
@@ -21,6 +64,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
|
|
||||||
<!-- "Unnamed" entity -->
|
<!-- "Unnamed" entity -->
|
||||||
<SpecialEntityCard
|
<SpecialEntityCard
|
||||||
|
v-if="showUnnamedEntity && !searchTerm.trim()"
|
||||||
entity-type="unnamed"
|
entity-type="unnamed"
|
||||||
:label="unnamedEntityName"
|
:label="unnamedEntityName"
|
||||||
icon="circle-question"
|
icon="circle-question"
|
||||||
@@ -32,22 +76,66 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state message -->
|
<!-- Empty state message -->
|
||||||
<li v-if="entities.length === 0" :class="emptyStateClasses">
|
<li v-if="hasNoEntities" :class="emptyStateClasses">
|
||||||
{{ emptyStateMessage }}
|
{{ emptyStateMessage }}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Entity cards (people or projects) -->
|
<!-- Entity cards (people or projects) -->
|
||||||
<template v-if="entityType === 'people'">
|
<template v-if="entityType === 'people'">
|
||||||
<PersonCard
|
<!-- When showing contacts without search: split into recent and alphabetical -->
|
||||||
v-for="person in displayedEntities as Contact[]"
|
<template v-if="!searchTerm.trim()">
|
||||||
:key="person.did"
|
<!-- Recently Added Section -->
|
||||||
:person="person"
|
<template v-if="recentContacts.length > 0">
|
||||||
:conflicted="isPersonConflicted(person.did)"
|
<li
|
||||||
:show-time-icon="true"
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
:notify="notify"
|
>
|
||||||
:conflict-context="conflictContext"
|
Recently Added
|
||||||
@person-selected="handlePersonSelected"
|
</li>
|
||||||
/>
|
<PersonCard
|
||||||
|
v-for="person in recentContacts"
|
||||||
|
:key="person.did"
|
||||||
|
:person="person"
|
||||||
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
|
:show-time-icon="true"
|
||||||
|
:notify="notify"
|
||||||
|
:conflict-context="conflictContext"
|
||||||
|
@person-selected="handlePersonSelected"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Alphabetical Section -->
|
||||||
|
<template v-if="alphabeticalContacts.length > 0">
|
||||||
|
<li
|
||||||
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
|
>
|
||||||
|
Everyone
|
||||||
|
</li>
|
||||||
|
<PersonCard
|
||||||
|
v-for="person in alphabeticalContacts"
|
||||||
|
:key="person.did"
|
||||||
|
:person="person"
|
||||||
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
|
:show-time-icon="true"
|
||||||
|
:notify="notify"
|
||||||
|
:conflict-context="conflictContext"
|
||||||
|
@person-selected="handlePersonSelected"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- When searching: show filtered results normally -->
|
||||||
|
<template v-else>
|
||||||
|
<PersonCard
|
||||||
|
v-for="person in displayedEntities as Contact[]"
|
||||||
|
:key="person.did"
|
||||||
|
:person="person"
|
||||||
|
:conflicted="isPersonConflicted(person.did)"
|
||||||
|
:show-time-icon="true"
|
||||||
|
:notify="notify"
|
||||||
|
:conflict-context="conflictContext"
|
||||||
|
@person-selected="handlePersonSelected"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="entityType === 'projects'">
|
<template v-else-if="entityType === 'projects'">
|
||||||
@@ -63,27 +151,30 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
@project-selected="handleProjectSelected"
|
@project-selected="handleProjectSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Show All navigation -->
|
|
||||||
<ShowAllCard
|
|
||||||
v-if="shouldShowAll"
|
|
||||||
:entity-type="entityType"
|
|
||||||
:route-name="showAllRoute"
|
|
||||||
:query-params="showAllQueryParams"
|
|
||||||
/>
|
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
|
||||||
|
import { useInfiniteScroll } from "@vueuse/core";
|
||||||
import PersonCard from "./PersonCard.vue";
|
import PersonCard from "./PersonCard.vue";
|
||||||
import ProjectCard from "./ProjectCard.vue";
|
import ProjectCard from "./ProjectCard.vue";
|
||||||
import SpecialEntityCard from "./SpecialEntityCard.vue";
|
import SpecialEntityCard from "./SpecialEntityCard.vue";
|
||||||
import ShowAllCard from "./ShowAllCard.vue";
|
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
import { PlanData } from "../interfaces/records";
|
import { PlanData } from "../interfaces/records";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import { getHeaders } from "../libs/endorserServer";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { TIMEOUTS } from "@/utils/notify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants for infinite scroll configuration
|
||||||
|
*/
|
||||||
|
const INITIAL_BATCH_SIZE = 20;
|
||||||
|
const INCREMENT_SIZE = 20;
|
||||||
|
const RECENT_CONTACTS_COUNT = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntityGrid - Unified grid layout for displaying people or projects
|
* EntityGrid - Unified grid layout for displaying people or projects
|
||||||
@@ -93,7 +184,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
|||||||
* - Special entity integration (You, Unnamed)
|
* - Special entity integration (You, Unnamed)
|
||||||
* - Conflict detection integration
|
* - Conflict detection integration
|
||||||
* - Empty state messaging
|
* - Empty state messaging
|
||||||
* - Show All navigation
|
|
||||||
* - Event delegation for entity selection
|
* - Event delegation for entity selection
|
||||||
* - Warning notifications for conflicted entities
|
* - Warning notifications for conflicted entities
|
||||||
* - Template streamlined with computed CSS properties
|
* - Template streamlined with computed CSS properties
|
||||||
@@ -104,21 +194,49 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
|||||||
PersonCard,
|
PersonCard,
|
||||||
ProjectCard,
|
ProjectCard,
|
||||||
SpecialEntityCard,
|
SpecialEntityCard,
|
||||||
ShowAllCard,
|
|
||||||
},
|
},
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class EntityGrid extends Vue {
|
export default class EntityGrid extends Vue {
|
||||||
/** Type of entities to display */
|
/** Type of entities to display */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
entityType!: "people" | "projects";
|
entityType!: "people" | "projects";
|
||||||
|
|
||||||
/** Array of entities to display */
|
// Search state
|
||||||
@Prop({ required: true })
|
searchTerm = "";
|
||||||
entities!: Contact[] | PlanData[];
|
isSearching = false;
|
||||||
|
searchTimeout: NodeJS.Timeout | null = null;
|
||||||
|
filteredEntities: Contact[] | PlanData[] = [];
|
||||||
|
searchBeforeId: string | undefined = undefined;
|
||||||
|
isLoadingSearchMore = false;
|
||||||
|
|
||||||
/** Maximum number of entities to display */
|
// API server for project searches
|
||||||
@Prop({ default: 10 })
|
apiServer = "";
|
||||||
maxItems!: number;
|
|
||||||
|
// Internal project state (when entities prop not provided for projects)
|
||||||
|
allProjects: PlanData[] = [];
|
||||||
|
loadBeforeId: string | undefined = undefined;
|
||||||
|
isLoadingProjects = false;
|
||||||
|
|
||||||
|
// Infinite scroll state
|
||||||
|
displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
infiniteScrollReset?: () => void;
|
||||||
|
scrollContainer?: HTMLElement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array of entities to display
|
||||||
|
*
|
||||||
|
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
|
||||||
|
* Use $contactsByDateAdded() to ensure all contacts are included.
|
||||||
|
* Client-side filtering assumes the complete list is available.
|
||||||
|
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
|
||||||
|
* (newest first) for the "Recently Added" section to display correctly.
|
||||||
|
*
|
||||||
|
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
|
||||||
|
* projects internally from the API server. If provided, uses the provided list.
|
||||||
|
*/
|
||||||
|
@Prop({ required: false })
|
||||||
|
entities?: Contact[] | PlanData[];
|
||||||
|
|
||||||
/** Active user's DID */
|
/** Active user's DID */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
@@ -140,18 +258,14 @@ export default class EntityGrid extends Vue {
|
|||||||
@Prop({ default: true })
|
@Prop({ default: true })
|
||||||
showYouEntity!: boolean;
|
showYouEntity!: boolean;
|
||||||
|
|
||||||
|
/** Whether to show the "Unnamed" entity for people grids */
|
||||||
|
@Prop({ default: true })
|
||||||
|
showUnnamedEntity!: boolean;
|
||||||
|
|
||||||
/** Whether the "You" entity is selectable */
|
/** Whether the "You" entity is selectable */
|
||||||
@Prop({ default: true })
|
@Prop({ default: true })
|
||||||
youSelectable!: boolean;
|
youSelectable!: boolean;
|
||||||
|
|
||||||
/** Route name for "Show All" navigation */
|
|
||||||
@Prop({ default: "" })
|
|
||||||
showAllRoute!: string;
|
|
||||||
|
|
||||||
/** Query parameters for "Show All" navigation */
|
|
||||||
@Prop({ default: () => ({}) })
|
|
||||||
showAllQueryParams!: Record<string, string>;
|
|
||||||
|
|
||||||
/** Notification function from parent component */
|
/** Notification function from parent component */
|
||||||
@Prop()
|
@Prop()
|
||||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
@@ -160,42 +274,31 @@ export default class EntityGrid extends Vue {
|
|||||||
@Prop({ default: "other party" })
|
@Prop({ default: "other party" })
|
||||||
conflictContext!: string;
|
conflictContext!: string;
|
||||||
|
|
||||||
/** Whether to hide the "Show All" navigation */
|
|
||||||
@Prop({ default: false })
|
|
||||||
hideShowAll!: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to determine which entities to display (allows parent control)
|
* Function to determine which entities to display (allows parent control)
|
||||||
*
|
*
|
||||||
* This function prop allows parent components to customize which entities
|
* This function prop allows parent components to customize which entities
|
||||||
* are displayed in the grid, enabling advanced filtering, sorting, and
|
* are displayed in the grid, enabling advanced filtering and sorting.
|
||||||
* display logic beyond the default simple slice behavior.
|
* Note: Infinite scroll is disabled when this prop is provided.
|
||||||
*
|
*
|
||||||
* @param entities - The full array of entities (Contact[] or PlanData[])
|
* @param entities - The full array of entities (Contact[] or PlanData[])
|
||||||
* @param entityType - The type of entities being displayed ("people" or "projects")
|
* @param entityType - The type of entities being displayed ("people" or "projects")
|
||||||
* @param maxItems - The maximum number of items to display (from maxItems prop)
|
|
||||||
* @returns Filtered/sorted array of entities to display
|
* @returns Filtered/sorted array of entities to display
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Custom filtering: only show contacts with profile images
|
* // Custom filtering: only show contacts with profile images
|
||||||
* :display-entities-function="(entities, type, max) =>
|
* :display-entities-function="(entities, type) =>
|
||||||
* entities.filter(e => e.profileImageUrl).slice(0, max)"
|
* entities.filter(e => e.profileImageUrl)"
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Custom sorting: sort projects by name
|
* // Custom sorting: sort projects by name
|
||||||
* :display-entities-function="(entities, type, max) =>
|
* :display-entities-function="(entities, type) =>
|
||||||
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
|
* entities.sort((a, b) => a.name.localeCompare(b.name))"
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Advanced logic: different limits for different entity types
|
|
||||||
* :display-entities-function="(entities, type, max) =>
|
|
||||||
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
|
|
||||||
*/
|
*/
|
||||||
@Prop({ default: null })
|
@Prop({ default: null })
|
||||||
displayEntitiesFunction?: (
|
displayEntitiesFunction?: (
|
||||||
entities: Contact[] | PlanData[],
|
entities: Contact[] | PlanData[],
|
||||||
entityType: "people" | "projects",
|
entityType: "people" | "projects",
|
||||||
maxItems: number,
|
|
||||||
) => Contact[] | PlanData[];
|
) => Contact[] | PlanData[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,33 +309,98 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed CSS classes for the grid layout
|
* Check if there are no entities to display
|
||||||
*/
|
*/
|
||||||
get gridClasses(): string {
|
get hasNoEntities(): boolean {
|
||||||
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
|
|
||||||
|
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
|
// For projects: check internal state if no entities prop, otherwise check prop
|
||||||
|
const projectsToCheck = this.entities || this.allProjects;
|
||||||
|
return projectsToCheck.length === 0;
|
||||||
} else {
|
} else {
|
||||||
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
|
// For people: entities prop is required
|
||||||
|
return !this.entities || this.entities.length === 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed entities to display - uses function prop if provided, otherwise defaults
|
* Get the entities array to use (prop or internal state)
|
||||||
|
*/
|
||||||
|
get entitiesToUse(): Contact[] | PlanData[] {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// For projects: use prop if provided, otherwise use internal state
|
||||||
|
return this.entities || this.allProjects;
|
||||||
|
} else {
|
||||||
|
// For people: entities prop is required
|
||||||
|
return this.entities || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
|
||||||
|
* When searching, returns filtered results with infinite scroll applied
|
||||||
*/
|
*/
|
||||||
get displayedEntities(): Contact[] | PlanData[] {
|
get displayedEntities(): Contact[] | PlanData[] {
|
||||||
if (this.displayEntitiesFunction) {
|
// If searching, return filtered results with infinite scroll
|
||||||
return this.displayEntitiesFunction(
|
if (this.searchTerm.trim()) {
|
||||||
this.entities,
|
return this.filteredEntities.slice(0, this.displayedCount);
|
||||||
this.entityType,
|
|
||||||
this.maxItems,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default implementation for backward compatibility
|
// If custom function provided, use it (disables infinite scroll)
|
||||||
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
|
if (this.displayEntitiesFunction) {
|
||||||
return this.entities.slice(0, maxDisplay);
|
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: projects use infinite scroll
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the most recently added contacts (when showing contacts and not searching)
|
||||||
|
*
|
||||||
|
* NOTE: This assumes entities are already sorted by date added (newest first).
|
||||||
|
* See the entities prop documentation for details on using $contactsByDateAdded().
|
||||||
|
*/
|
||||||
|
get recentContacts(): Contact[] {
|
||||||
|
if (
|
||||||
|
this.entityType !== "people" ||
|
||||||
|
this.searchTerm.trim() ||
|
||||||
|
!this.entities
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Entities are already sorted by date added (newest first)
|
||||||
|
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
|
||||||
|
* Uses infinite scroll to control how many are displayed
|
||||||
|
*/
|
||||||
|
get alphabeticalContacts(): Contact[] {
|
||||||
|
if (
|
||||||
|
this.entityType !== "people" ||
|
||||||
|
this.searchTerm.trim() ||
|
||||||
|
!this.entities
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Skip the first few (recent contacts) and sort the rest alphabetically
|
||||||
|
// Create a copy to avoid mutating the original array
|
||||||
|
const remaining = this.entities as Contact[];
|
||||||
|
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
|
||||||
|
// Sort alphabetically by name, falling back to DID if name is missing
|
||||||
|
const nameA = (a.name || a.did).toLowerCase();
|
||||||
|
const nameB = (b.name || b.did).toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
|
||||||
|
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
||||||
|
return sorted.slice(0, toShow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -246,15 +414,6 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to show the "Show All" navigation
|
|
||||||
*/
|
|
||||||
get shouldShowAll(): boolean {
|
|
||||||
return (
|
|
||||||
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the "You" entity is conflicted
|
* Whether the "You" entity is conflicted
|
||||||
*/
|
*/
|
||||||
@@ -328,6 +487,440 @@ export default class EntityGrid extends Vue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle search input with debouncing
|
||||||
|
*/
|
||||||
|
handleSearchInput(): void {
|
||||||
|
// Show spinner immediately when user types
|
||||||
|
this.isSearching = true;
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (this.searchTimeout) {
|
||||||
|
clearTimeout(this.searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout for 500ms delay
|
||||||
|
this.searchTimeout = setTimeout(() => {
|
||||||
|
this.performSearch();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the actual search
|
||||||
|
* Routes to server-side search for projects or client-side filtering for contacts
|
||||||
|
*/
|
||||||
|
async performSearch(): Promise<void> {
|
||||||
|
if (!this.searchTerm.trim()) {
|
||||||
|
this.filteredEntities = [];
|
||||||
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSearching = true;
|
||||||
|
this.searchBeforeId = undefined; // Reset pagination for new search
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// Server-side search for projects (initial load, no beforeId)
|
||||||
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
await this.fetchProjects(undefined, searchLower);
|
||||||
|
} else {
|
||||||
|
// Client-side filtering for contacts (complete list)
|
||||||
|
await this.performContactSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset displayed count when search completes
|
||||||
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} finally {
|
||||||
|
this.isSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch projects from API server
|
||||||
|
* Unified method for both loading all projects and searching projects.
|
||||||
|
* If claimContents is provided, performs search and updates filteredEntities.
|
||||||
|
* If claimContents is not provided, loads all projects and updates allProjects.
|
||||||
|
*
|
||||||
|
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
||||||
|
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
|
||||||
|
*/
|
||||||
|
async fetchProjects(
|
||||||
|
beforeId?: string,
|
||||||
|
claimContents?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.apiServer) {
|
||||||
|
if (claimContents) {
|
||||||
|
this.filteredEntities = [];
|
||||||
|
} else {
|
||||||
|
this.allProjects = [];
|
||||||
|
}
|
||||||
|
if (this.notify) {
|
||||||
|
this.notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "API server not configured",
|
||||||
|
},
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSearch = !!claimContents;
|
||||||
|
let url = `${this.apiServer}/api/v2/report/plans`;
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const params: string[] = [];
|
||||||
|
if (claimContents) {
|
||||||
|
params.push(
|
||||||
|
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (beforeId) {
|
||||||
|
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
|
||||||
|
}
|
||||||
|
if (params.length > 0) {
|
||||||
|
url += `?${params.join("&")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: await getHeaders(this.activeDid),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
isSearch ? "Failed to search projects" : "Failed to load projects",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
if (results.data) {
|
||||||
|
const newProjects = results.data.map(
|
||||||
|
(plan: PlanData & { rowId?: string }) => ({
|
||||||
|
...plan,
|
||||||
|
rowId: plan.rowId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSearch) {
|
||||||
|
// Search mode: update filteredEntities
|
||||||
|
if (beforeId) {
|
||||||
|
// Pagination: append new projects to existing search results
|
||||||
|
this.filteredEntities.push(...newProjects);
|
||||||
|
} else {
|
||||||
|
// Initial search: replace array
|
||||||
|
this.filteredEntities = newProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update searchBeforeId for next pagination
|
||||||
|
if (newProjects.length > 0) {
|
||||||
|
const lastProject = newProjects[newProjects.length - 1];
|
||||||
|
this.searchBeforeId = lastProject.rowId || undefined;
|
||||||
|
} else {
|
||||||
|
this.searchBeforeId = undefined; // No more results
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Load mode: update allProjects
|
||||||
|
if (beforeId) {
|
||||||
|
// Pagination: append new projects
|
||||||
|
this.allProjects.push(...newProjects);
|
||||||
|
} else {
|
||||||
|
// Initial load: replace array
|
||||||
|
this.allProjects = newProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update loadBeforeId for next pagination
|
||||||
|
if (newProjects.length > 0) {
|
||||||
|
const lastProject = newProjects[newProjects.length - 1];
|
||||||
|
this.loadBeforeId = lastProject.rowId || undefined;
|
||||||
|
} else {
|
||||||
|
this.loadBeforeId = undefined; // No more results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No data in response
|
||||||
|
if (isSearch) {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial search, not pagination
|
||||||
|
this.filteredEntities = [];
|
||||||
|
}
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
} else {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial load, not pagination
|
||||||
|
this.allProjects = [];
|
||||||
|
}
|
||||||
|
this.loadBeforeId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error ${isSearch ? "searching" : "loading"} projects:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (isSearch) {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial search error, not pagination error
|
||||||
|
this.filteredEntities = [];
|
||||||
|
}
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
} else {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial load error, not pagination error
|
||||||
|
this.allProjects = [];
|
||||||
|
}
|
||||||
|
this.loadBeforeId = undefined;
|
||||||
|
}
|
||||||
|
if (this.notify) {
|
||||||
|
this.notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: isSearch
|
||||||
|
? "Failed to search projects. Please try again."
|
||||||
|
: "Failed to load projects. Please try again.",
|
||||||
|
},
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side contact search
|
||||||
|
* Assumes entities prop contains complete contact list from local database
|
||||||
|
*/
|
||||||
|
async performContactSearch(): Promise<void> {
|
||||||
|
if (!this.entities) {
|
||||||
|
this.filteredEntities = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate async (for consistency with project search)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
|
this.filteredEntities = (this.entities as Contact[])
|
||||||
|
.filter((contact: Contact) => {
|
||||||
|
const name = contact.name?.toLowerCase() || "";
|
||||||
|
const did = contact.did.toLowerCase();
|
||||||
|
return name.includes(searchLower) || did.includes(searchLower);
|
||||||
|
})
|
||||||
|
.sort((a: Contact, b: Contact) => {
|
||||||
|
// Sort alphabetically by name, falling back to DID if name is missing
|
||||||
|
const nameA = (a.name || a.did).toLowerCase();
|
||||||
|
const nameB = (b.name || b.did).toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contacts don't need pagination (complete list)
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search
|
||||||
|
*/
|
||||||
|
clearSearch(): void {
|
||||||
|
this.searchTerm = "";
|
||||||
|
this.filteredEntities = [];
|
||||||
|
this.isSearching = false;
|
||||||
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
|
||||||
|
// Clear any pending timeout
|
||||||
|
if (this.searchTimeout) {
|
||||||
|
clearTimeout(this.searchTimeout);
|
||||||
|
this.searchTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if more entities can be loaded
|
||||||
|
*/
|
||||||
|
canLoadMore(): boolean {
|
||||||
|
if (this.displayEntitiesFunction) {
|
||||||
|
// Custom function disables infinite scroll
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.searchTerm.trim()) {
|
||||||
|
// Search mode: check if more results available
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// Projects: can load more if:
|
||||||
|
// 1. We have more already-loaded results to show, OR
|
||||||
|
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
|
||||||
|
const hasMoreLoaded =
|
||||||
|
this.displayedCount < this.filteredEntities.length;
|
||||||
|
const canLoadMoreFromServer =
|
||||||
|
this.displayedCount >= this.filteredEntities.length &&
|
||||||
|
!!this.searchBeforeId &&
|
||||||
|
!this.isLoadingSearchMore;
|
||||||
|
return hasMoreLoaded || canLoadMoreFromServer;
|
||||||
|
} else {
|
||||||
|
// Contacts: client-side filtering returns all results at once
|
||||||
|
return this.displayedCount < this.filteredEntities.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-search mode
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// Projects: check internal state or prop
|
||||||
|
const projectsToCheck = this.entities || this.allProjects;
|
||||||
|
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
||||||
|
|
||||||
|
// Can load more if:
|
||||||
|
// 1. We have more already-loaded results to show, OR
|
||||||
|
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
|
||||||
|
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
|
||||||
|
const canLoadMoreFromServer =
|
||||||
|
!this.entities &&
|
||||||
|
this.displayedCount >= projectsToCheck.length &&
|
||||||
|
!!beforeId &&
|
||||||
|
!this.isLoadingProjects;
|
||||||
|
|
||||||
|
return hasMoreLoaded || canLoadMoreFromServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// People: check if more alphabetical contacts available
|
||||||
|
// Total available = recent + all alphabetical
|
||||||
|
if (!this.entities) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
|
||||||
|
return this.displayedCount < totalAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize infinite scroll on mount
|
||||||
|
*/
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
// Load apiServer for project searches/loads
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.apiServer = settings.apiServer || "";
|
||||||
|
|
||||||
|
// Load projects on mount if entities prop not provided
|
||||||
|
if (!this.entities && this.apiServer) {
|
||||||
|
this.isLoadingProjects = true;
|
||||||
|
try {
|
||||||
|
await this.fetchProjects();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading projects on mount:", error);
|
||||||
|
} finally {
|
||||||
|
this.isLoadingProjects = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entities prop for people
|
||||||
|
if (this.entityType === "people" && !this.entities) {
|
||||||
|
logger.error(
|
||||||
|
"EntityGrid: entities prop is required when entityType is 'people'",
|
||||||
|
);
|
||||||
|
if (this.notify) {
|
||||||
|
this.notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Contacts data is required but not provided.",
|
||||||
|
},
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const container = this.$refs.scrollContainer as HTMLElement;
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
const { reset } = useInfiniteScroll(
|
||||||
|
container,
|
||||||
|
async () => {
|
||||||
|
// Search mode: handle search pagination
|
||||||
|
if (this.searchTerm.trim()) {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// Projects: load more search results if available
|
||||||
|
if (
|
||||||
|
this.displayedCount >= this.filteredEntities.length &&
|
||||||
|
this.searchBeforeId &&
|
||||||
|
!this.isLoadingSearchMore
|
||||||
|
) {
|
||||||
|
this.isLoadingSearchMore = true;
|
||||||
|
try {
|
||||||
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
await this.fetchProjects(this.searchBeforeId, searchLower);
|
||||||
|
// After loading more, reset scroll state to allow further loading
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading more search results:", error);
|
||||||
|
// Error already handled in fetchProjects
|
||||||
|
} finally {
|
||||||
|
this.isLoadingSearchMore = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show more from already-loaded search results
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Contacts: show more from already-filtered results
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-search mode
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
const projectsToCheck = this.entities || this.allProjects;
|
||||||
|
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
||||||
|
|
||||||
|
// If using internal state and need to load more from server
|
||||||
|
if (
|
||||||
|
!this.entities &&
|
||||||
|
this.displayedCount >= projectsToCheck.length &&
|
||||||
|
beforeId &&
|
||||||
|
!this.isLoadingProjects
|
||||||
|
) {
|
||||||
|
this.isLoadingProjects = true;
|
||||||
|
try {
|
||||||
|
await this.fetchProjects(beforeId);
|
||||||
|
// After loading more, reset scroll state to allow further loading
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading more projects:", error);
|
||||||
|
// Error already handled in fetchProjects
|
||||||
|
} finally {
|
||||||
|
this.isLoadingProjects = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal case: increment displayedCount to show more from memory
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// People: increment displayedCount to show more from memory
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
distance: 50, // pixels from bottom
|
||||||
|
canLoadMore: () => this.canLoadMore(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.infiniteScrollReset = reset;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Emit methods using @Emit decorator
|
// Emit methods using @Emit decorator
|
||||||
|
|
||||||
@Emit("entity-selected")
|
@Emit("entity-selected")
|
||||||
@@ -340,6 +933,47 @@ export default class EntityGrid extends Vue {
|
|||||||
} {
|
} {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for changes in search term to reset displayed count and pagination
|
||||||
|
*/
|
||||||
|
@Watch("searchTerm")
|
||||||
|
onSearchTermChange(): void {
|
||||||
|
// Reset displayed count and pagination when search term changes
|
||||||
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for changes in entities prop to clear search and reset displayed count
|
||||||
|
*/
|
||||||
|
@Watch("entities")
|
||||||
|
onEntitiesChange(): void {
|
||||||
|
// Clear search when entities change (fresh dialog open)
|
||||||
|
if (this.searchTerm) {
|
||||||
|
this.searchTerm = "";
|
||||||
|
this.filteredEntities = [];
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
}
|
||||||
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
|
||||||
|
// For projects: if entities prop is provided, clear internal state
|
||||||
|
if (this.entityType === "projects" && this.entities) {
|
||||||
|
this.allProjects = [];
|
||||||
|
this.loadBeforeId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup timeouts when component is destroyed
|
||||||
|
*/
|
||||||
|
beforeUnmount(): void {
|
||||||
|
if (this.searchTimeout) {
|
||||||
|
clearTimeout(this.searchTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
|
|||||||
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
|
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
|
||||||
based on context * - EntityGrid integration for unified entity display * -
|
based on context * - EntityGrid integration for unified entity display * -
|
||||||
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
|
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
|
||||||
Show All navigation with context preservation * - Cancel functionality * - Event
|
Cancel functionality * - Event delegation for entity selection * - Warning
|
||||||
delegation for entity selection * - Warning notifications for conflicted
|
notifications for conflicted entities * - Template streamlined with computed CSS
|
||||||
entities * - Template streamlined with computed CSS properties * * @author
|
properties * * @author Matthew Raymer */
|
||||||
Matthew Raymer */
|
|
||||||
<template>
|
<template>
|
||||||
<div id="sectionGiftedGiver">
|
<div id="sectionGiftedGiver">
|
||||||
<label class="block font-bold mb-4">
|
<label class="block font-bold mb-4">
|
||||||
@@ -15,19 +14,15 @@ Matthew Raymer */
|
|||||||
|
|
||||||
<EntityGrid
|
<EntityGrid
|
||||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||||
:entities="shouldShowProjects ? projects : allContacts"
|
:entities="shouldShowProjects ? projects || undefined : allContacts"
|
||||||
:max-items="10"
|
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:conflict-checker="conflictChecker"
|
:conflict-checker="conflictChecker"
|
||||||
:show-you-entity="shouldShowYouEntity"
|
:show-you-entity="shouldShowYouEntity"
|
||||||
:you-selectable="youSelectable"
|
:you-selectable="youSelectable"
|
||||||
:show-all-route="showAllRoute"
|
|
||||||
:show-all-query-params="showAllQueryParams"
|
|
||||||
:notify="notify"
|
:notify="notify"
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
:hide-show-all="hideShowAll"
|
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -68,7 +63,6 @@ interface EntitySelectionEvent {
|
|||||||
* - EntityGrid integration for unified entity display
|
* - EntityGrid integration for unified entity display
|
||||||
* - Conflict detection and prevention
|
* - Conflict detection and prevention
|
||||||
* - Special entity handling (You, Unnamed)
|
* - Special entity handling (You, Unnamed)
|
||||||
* - Show All navigation with context preservation
|
|
||||||
* - Cancel functionality
|
* - Cancel functionality
|
||||||
* - Event delegation for entity selection
|
* - Event delegation for entity selection
|
||||||
* - Warning notifications for conflicted entities
|
* - Warning notifications for conflicted entities
|
||||||
@@ -100,9 +94,9 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop({ default: false })
|
@Prop({ default: false })
|
||||||
isFromProjectView!: boolean;
|
isFromProjectView!: boolean;
|
||||||
|
|
||||||
/** Array of available projects */
|
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
|
||||||
@Prop({ required: true })
|
@Prop({ required: false })
|
||||||
projects!: PlanData[];
|
projects?: PlanData[];
|
||||||
|
|
||||||
/** Array of available contacts */
|
/** Array of available contacts */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
@@ -154,10 +148,6 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop()
|
@Prop()
|
||||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
/** Whether to hide the "Show All" navigation */
|
|
||||||
@Prop({ default: false })
|
|
||||||
hideShowAll!: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the cancel button
|
* CSS classes for the cancel button
|
||||||
*/
|
*/
|
||||||
@@ -222,59 +212,6 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
return !this.conflictChecker(this.activeDid);
|
return !this.conflictChecker(this.activeDid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Route name for "Show All" navigation
|
|
||||||
*/
|
|
||||||
get showAllRoute(): string {
|
|
||||||
if (this.shouldShowProjects) {
|
|
||||||
return "discover";
|
|
||||||
} else if (this.allContacts.length > 0) {
|
|
||||||
return "contact-gift";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query parameters for "Show All" navigation
|
|
||||||
*/
|
|
||||||
get showAllQueryParams(): Record<string, string> {
|
|
||||||
const baseParams = {
|
|
||||||
stepType: this.stepType,
|
|
||||||
giverEntityType: this.giverEntityType,
|
|
||||||
recipientEntityType: this.recipientEntityType,
|
|
||||||
// Form field values to preserve
|
|
||||||
description: this.description,
|
|
||||||
amountInput: this.amountInput,
|
|
||||||
unitCode: this.unitCode,
|
|
||||||
offerId: this.offerId,
|
|
||||||
fromProjectId: this.fromProjectId,
|
|
||||||
toProjectId: this.toProjectId,
|
|
||||||
showProjects: this.showProjects.toString(),
|
|
||||||
isFromProjectView: this.isFromProjectView.toString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.shouldShowProjects) {
|
|
||||||
// For project contexts, still pass entity type information
|
|
||||||
return baseParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseParams,
|
|
||||||
// Always pass both giver and recipient info for context preservation
|
|
||||||
giverProjectId: this.fromProjectId || "",
|
|
||||||
giverProjectName: this.giver?.name || "",
|
|
||||||
giverProjectImage: this.giver?.image || "",
|
|
||||||
giverProjectHandleId: this.giver?.handleId || "",
|
|
||||||
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
|
|
||||||
recipientProjectId: this.toProjectId || "",
|
|
||||||
recipientProjectName: this.receiver?.name || "",
|
|
||||||
recipientProjectImage: this.receiver?.image || "",
|
|
||||||
recipientProjectHandleId: this.receiver?.handleId || "",
|
|
||||||
recipientDid:
|
|
||||||
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle entity selection from EntityGrid
|
* Handle entity selection from EntityGrid
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
giverEntityType === 'project' || recipientEntityType === 'project'
|
giverEntityType === 'project' || recipientEntityType === 'project'
|
||||||
"
|
"
|
||||||
:is-from-project-view="isFromProjectView"
|
:is-from-project-view="isFromProjectView"
|
||||||
:projects="projects"
|
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
:unit-code="unitCode"
|
:unit-code="unitCode"
|
||||||
:offer-id="offerId"
|
:offer-id="offerId"
|
||||||
:notify="$notify"
|
:notify="$notify"
|
||||||
:hide-show-all="hideShowAll"
|
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
@cancel="cancel"
|
@cancel="cancel"
|
||||||
/>
|
/>
|
||||||
@@ -69,7 +67,6 @@ import {
|
|||||||
createAndSubmitGive,
|
createAndSubmitGive,
|
||||||
didInfo,
|
didInfo,
|
||||||
serverMessageForUser,
|
serverMessageForUser,
|
||||||
getHeaders,
|
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import * as libsUtil from "../libs/util";
|
import * as libsUtil from "../libs/util";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
@@ -117,7 +114,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
@Prop() fromProjectId = "";
|
@Prop() fromProjectId = "";
|
||||||
@Prop() toProjectId = "";
|
@Prop() toProjectId = "";
|
||||||
@Prop() isFromProjectView = false;
|
@Prop() isFromProjectView = false;
|
||||||
@Prop() hideShowAll = false;
|
|
||||||
@Prop({ default: "person" }) giverEntityType = "person" as
|
@Prop({ default: "person" }) giverEntityType = "person" as
|
||||||
| "person"
|
| "person"
|
||||||
| "project";
|
| "project";
|
||||||
@@ -136,7 +132,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
||||||
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||||
offerId = "";
|
offerId = "";
|
||||||
projects: PlanData[] = [];
|
|
||||||
prompt = "";
|
prompt = "";
|
||||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||||
stepType = "giver";
|
stepType = "giver";
|
||||||
@@ -233,19 +228,9 @@ export default class GiftedDialog extends Vue {
|
|||||||
apiServer: this.apiServer,
|
apiServer: this.apiServer,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.allContacts = await this.$contacts();
|
this.allContacts = await this.$contactsByDateAdded();
|
||||||
|
|
||||||
this.allMyDids = await retrieveAccountDids();
|
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) {
|
} catch (err: unknown) {
|
||||||
logger.error("Error retrieving settings from database:", err);
|
logger.error("Error retrieving settings from database:", err);
|
||||||
this.safeNotify.error(
|
this.safeNotify.error(
|
||||||
@@ -491,27 +476,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.firstStep = false;
|
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) {
|
selectProject(project: PlanData) {
|
||||||
this.giver = {
|
this.giver = {
|
||||||
did: project.handleId,
|
did: project.handleId,
|
||||||
@@ -519,10 +483,13 @@ export default class GiftedDialog extends Vue {
|
|||||||
image: project.image,
|
image: project.image,
|
||||||
handleId: project.handleId,
|
handleId: project.handleId,
|
||||||
};
|
};
|
||||||
this.receiver = {
|
// Only set receiver to "You" if no receiver has been selected yet
|
||||||
did: this.activeDid,
|
if (!this.receiver || !this.receiver.did) {
|
||||||
name: "You",
|
this.receiver = {
|
||||||
};
|
did: this.activeDid,
|
||||||
|
name: "You",
|
||||||
|
};
|
||||||
|
}
|
||||||
this.firstStep = false;
|
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>
|
||||||
@@ -3,30 +3,25 @@ GiftedDialog.vue to handle person entity display * with selection states and
|
|||||||
conflict detection. * * @author Matthew Raymer */
|
conflict detection. * * @author Matthew Raymer */
|
||||||
<template>
|
<template>
|
||||||
<li :class="cardClasses" @click="handleClick">
|
<li :class="cardClasses" @click="handleClick">
|
||||||
<div class="relative w-fit mx-auto">
|
<div>
|
||||||
<EntityIcon
|
<EntityIcon
|
||||||
v-if="person.did"
|
v-if="person.did"
|
||||||
:contact="person"
|
:contact="person"
|
||||||
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
/>
|
/>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
v-else
|
v-else
|
||||||
icon="circle-question"
|
icon="circle-question"
|
||||||
class="text-slate-400 text-5xl mb-1"
|
class="text-slate-400 text-5xl mb-1 shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Time icon overlay for contacts -->
|
|
||||||
<div
|
|
||||||
v-if="person.did && showTimeIcon"
|
|
||||||
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
|
|
||||||
>
|
|
||||||
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 :class="nameClasses">
|
<div class="overflow-hidden">
|
||||||
{{ displayName }}
|
<h3 :class="nameClasses">
|
||||||
</h3>
|
{{ displayName }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -81,29 +76,32 @@ export default class PersonCard extends Vue {
|
|||||||
* Computed CSS classes for the card
|
* Computed CSS classes for the card
|
||||||
*/
|
*/
|
||||||
get cardClasses(): string {
|
get cardClasses(): string {
|
||||||
|
const baseCardClasses =
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
|
||||||
|
|
||||||
if (!this.selectable || this.conflicted) {
|
if (!this.selectable || this.conflicted) {
|
||||||
return "opacity-50 cursor-not-allowed";
|
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
|
||||||
}
|
}
|
||||||
return "cursor-pointer hover:bg-slate-50";
|
|
||||||
|
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed CSS classes for the person name
|
* Computed CSS classes for the person name
|
||||||
*/
|
*/
|
||||||
get nameClasses(): string {
|
get nameClasses(): string {
|
||||||
const baseClasses =
|
const baseNameClasses = "text-sm font-semibold truncate";
|
||||||
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
|
|
||||||
|
|
||||||
if (this.conflicted) {
|
if (this.conflicted) {
|
||||||
return `${baseClasses} text-slate-400`;
|
return `${baseNameClasses} text-slate-500`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add italic styling for entities without set names
|
// Add italic styling for entities without set names
|
||||||
if (!this.person.name) {
|
if (!this.person.name) {
|
||||||
return `${baseClasses} italic text-slate-500`;
|
return `${baseNameClasses} italic text-slate-500`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseClasses;
|
return baseNameClasses;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,25 +2,26 @@
|
|||||||
GiftedDialog.vue to handle project entity display * with selection states and
|
GiftedDialog.vue to handle project entity display * with selection states and
|
||||||
issuer information. * * @author Matthew Raymer */
|
issuer information. * * @author Matthew Raymer */
|
||||||
<template>
|
<template>
|
||||||
<li class="cursor-pointer" @click="handleClick">
|
<li
|
||||||
<div class="relative w-fit mx-auto">
|
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
|
||||||
<ProjectIcon
|
@click="handleClick"
|
||||||
:entity-id="project.handleId"
|
>
|
||||||
:icon-size="48"
|
<ProjectIcon
|
||||||
:image-url="project.image"
|
:entity-id="project.handleId"
|
||||||
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
|
:icon-size="30"
|
||||||
/>
|
:image-url="project.image"
|
||||||
</div>
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
|
/>
|
||||||
|
|
||||||
<h3
|
<div class="overflow-hidden">
|
||||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
<h3 class="text-sm font-semibold truncate">
|
||||||
>
|
{{ project.name || unnamedProject }}
|
||||||
{{ project.name || unnamedProject }}
|
</h3>
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="text-xs text-slate-500 truncate">
|
<div class="text-xs text-slate-500 truncate">
|
||||||
<font-awesome icon="user" class="fa-fw text-slate-400" />
|
<font-awesome icon="user" class="text-slate-400" />
|
||||||
{{ issuerDisplayName }}
|
{{ issuerDisplayName }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
117
src/components/ProjectRepresentativeDialog.vue
Normal file
117
src/components/ProjectRepresentativeDialog.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-lg font-semibold leading-[1.25] mb-4">
|
||||||
|
Select Representative
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- EntityGrid for contacts -->
|
||||||
|
<EntityGrid
|
||||||
|
:entity-type="'people'"
|
||||||
|
:entities="allContacts"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:conflict-checker="() => false"
|
||||||
|
:show-you-entity="false"
|
||||||
|
:show-unnamed-entity="false"
|
||||||
|
:notify="notify"
|
||||||
|
:conflict-context="'representative'"
|
||||||
|
@entity-selected="handleEntitySelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cancel Button -->
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||||
|
import EntityGrid from "./EntityGrid.vue";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import { NotificationIface } from "../constants/app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProjectRepresentativeDialog - Dialog for selecting an authorized representative
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - EntityGrid integration for contact selection
|
||||||
|
* - No special entities (You, Unnamed)
|
||||||
|
* - Immediate assignment on contact selection
|
||||||
|
* - Cancel button to close without selection
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
EntityGrid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class ProjectRepresentativeDialog extends Vue {
|
||||||
|
/** Whether the dialog is visible */
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
/** Array of available contacts */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allContacts!: Contact[];
|
||||||
|
|
||||||
|
/** Active user's DID */
|
||||||
|
@Prop({ required: true })
|
||||||
|
activeDid!: string;
|
||||||
|
|
||||||
|
/** All user's DIDs */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allMyDids!: string[];
|
||||||
|
|
||||||
|
/** Notification function from parent component */
|
||||||
|
@Prop()
|
||||||
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle entity selection from EntityGrid
|
||||||
|
* Immediately assigns the selected contact and closes the dialog
|
||||||
|
*/
|
||||||
|
handleEntitySelected(event: { type: "person" | "project"; data: Contact }) {
|
||||||
|
const contact = event.data as Contact;
|
||||||
|
this.emitAssign(contact);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancel button click
|
||||||
|
*/
|
||||||
|
handleCancel(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the dialog
|
||||||
|
*/
|
||||||
|
open(): void {
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the dialog
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit methods using @Emit decorator
|
||||||
|
|
||||||
|
@Emit("assign")
|
||||||
|
emitAssign(contact: Contact): Contact {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
|
|
||||||
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
|
|
||||||
entity types. * * @author Matthew Raymer */
|
|
||||||
<template>
|
|
||||||
<li class="cursor-pointer">
|
|
||||||
<router-link :to="navigationRoute" class="block text-center">
|
|
||||||
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
|
|
||||||
<h3
|
|
||||||
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
|
|
||||||
>
|
|
||||||
Show All
|
|
||||||
</h3>
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
|
||||||
import { RouteLocationRaw } from "vue-router";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ShowAllCard - Displays "Show All" navigation for entity grids
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Provides navigation to full entity listings
|
|
||||||
* - Supports different routes based on entity type
|
|
||||||
* - Maintains context through query parameters
|
|
||||||
* - Consistent visual styling with other cards
|
|
||||||
*/
|
|
||||||
@Component({ name: "ShowAllCard" })
|
|
||||||
export default class ShowAllCard extends Vue {
|
|
||||||
/** Type of entities being shown */
|
|
||||||
@Prop({ required: true })
|
|
||||||
entityType!: "people" | "projects";
|
|
||||||
|
|
||||||
/** Route name to navigate to */
|
|
||||||
@Prop({ required: true })
|
|
||||||
routeName!: string;
|
|
||||||
|
|
||||||
/** Query parameters to pass to the route */
|
|
||||||
@Prop({ default: () => ({}) })
|
|
||||||
queryParams!: Record<string, string>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computed navigation route with query parameters
|
|
||||||
*/
|
|
||||||
get navigationRoute(): RouteLocationRaw {
|
|
||||||
return {
|
|
||||||
name: this.routeName,
|
|
||||||
query: this.queryParams,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Ensure router-link styling is consistent */
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover .fa-circle-right {
|
|
||||||
transform: scale(1.1);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -63,23 +63,24 @@ export default class SpecialEntityCard extends Vue {
|
|||||||
conflictContext!: string;
|
conflictContext!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed CSS classes for the card container
|
* Computed CSS classes for the card
|
||||||
*/
|
*/
|
||||||
get cardClasses(): string {
|
get cardClasses(): string {
|
||||||
const baseClasses = "block";
|
const baseCardClasses =
|
||||||
|
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
|
||||||
|
|
||||||
if (!this.selectable || this.conflicted) {
|
if (!this.selectable || this.conflicted) {
|
||||||
return `${baseClasses} cursor-not-allowed opacity-50`;
|
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${baseClasses} cursor-pointer`;
|
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed CSS classes for the icon
|
* Computed CSS classes for the icon
|
||||||
*/
|
*/
|
||||||
get iconClasses(): string {
|
get iconClasses(): string {
|
||||||
const baseClasses = "text-5xl mb-1";
|
const baseClasses = "text-[2rem]";
|
||||||
|
|
||||||
if (this.conflicted) {
|
if (this.conflicted) {
|
||||||
return `${baseClasses} text-slate-400`;
|
return `${baseClasses} text-slate-400`;
|
||||||
@@ -101,7 +102,7 @@ export default class SpecialEntityCard extends Vue {
|
|||||||
*/
|
*/
|
||||||
get nameClasses(): string {
|
get nameClasses(): string {
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
|
"text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
|
||||||
|
|
||||||
if (this.conflicted) {
|
if (this.conflicted) {
|
||||||
return `${baseClasses} text-slate-400`;
|
return `${baseClasses} text-slate-400`;
|
||||||
|
|||||||
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({
|
"user-profile": z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
}),
|
}),
|
||||||
|
"shared-photo": z.object({}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deepLinkQuerySchemas = {
|
export const deepLinkQuerySchemas = {
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
|
|||||||
planName: string;
|
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 {
|
export interface PlanSummaryRecord {
|
||||||
agentDid?: string;
|
agentDid?: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -76,7 +81,9 @@ export interface PlanSummaryRecord {
|
|||||||
|
|
||||||
export interface PlanSummaryAndPreviousClaim {
|
export interface PlanSummaryAndPreviousClaim {
|
||||||
plan: PlanSummaryRecord;
|
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:",
|
"Registration thrown error:",
|
||||||
errorMessage || JSON.stringify(err),
|
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." };
|
return { error: "Got a server error when registering." };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
faDownload,
|
faDownload,
|
||||||
faEllipsis,
|
faEllipsis,
|
||||||
faEllipsisVertical,
|
faEllipsisVertical,
|
||||||
|
faEnvelope,
|
||||||
faEnvelopeOpenText,
|
faEnvelopeOpenText,
|
||||||
faEraser,
|
faEraser,
|
||||||
faEye,
|
faEye,
|
||||||
@@ -101,6 +102,9 @@ import {
|
|||||||
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
|
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
|
||||||
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
|
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
|
// Initialize Font Awesome library with all required icons
|
||||||
library.add(
|
library.add(
|
||||||
faArrowDown,
|
faArrowDown,
|
||||||
@@ -140,6 +144,7 @@ library.add(
|
|||||||
faDownload,
|
faDownload,
|
||||||
faEllipsis,
|
faEllipsis,
|
||||||
faEllipsisVertical,
|
faEllipsisVertical,
|
||||||
|
faEnvelope,
|
||||||
faEnvelopeOpenText,
|
faEnvelopeOpenText,
|
||||||
faEraser,
|
faEraser,
|
||||||
faEye,
|
faEye,
|
||||||
@@ -193,6 +198,7 @@ library.add(
|
|||||||
faTriangleExclamation,
|
faTriangleExclamation,
|
||||||
faUser,
|
faUser,
|
||||||
faUsers,
|
faUsers,
|
||||||
|
faWhatsapp,
|
||||||
faXmark,
|
faXmark,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,15 @@
|
|||||||
|
|
||||||
import { initializeApp } from "./main.common";
|
import { initializeApp } from "./main.common";
|
||||||
import { App as CapacitorApp } from "@capacitor/app";
|
import { App as CapacitorApp } from "@capacitor/app";
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
import { handleApiError } from "./services/api";
|
import { handleApiError } from "./services/api";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { DeepLinkHandler } from "./services/deepLinks";
|
import { DeepLinkHandler } from "./services/deepLinks";
|
||||||
import { logger, safeStringify } from "./utils/logger";
|
import { logger, safeStringify } from "./utils/logger";
|
||||||
|
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
|
||||||
|
import { SHARED_PHOTO_BASE64_KEY } from "./libs/util";
|
||||||
import "./utils/safeAreaInset";
|
import "./utils/safeAreaInset";
|
||||||
|
|
||||||
logger.log("[Capacitor] 🚀 Starting initialization");
|
logger.log("[Capacitor] 🚀 Starting initialization");
|
||||||
@@ -67,11 +71,220 @@ const deepLinkHandler = new DeepLinkHandler(router);
|
|||||||
*
|
*
|
||||||
* @throws {Error} If URL format is invalid
|
* @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 handleDeepLink = async (data: { url: string }) => {
|
||||||
const { url } = data;
|
const { url } = data;
|
||||||
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
||||||
|
|
||||||
try {
|
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
|
// Wait for router to be ready
|
||||||
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
|
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
|
|||||||
@@ -91,16 +91,92 @@ export class CapacitorPlatformService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create/Open database
|
// Try to create/Open database connection
|
||||||
this.db = await this.sqlite.createConnection(
|
try {
|
||||||
this.dbName,
|
this.db = await this.sqlite.createConnection(
|
||||||
false,
|
this.dbName,
|
||||||
"no-encryption",
|
false,
|
||||||
1,
|
"no-encryption",
|
||||||
false,
|
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
|
// Set journal mode to WAL for better performance
|
||||||
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
<EntityGrid
|
<EntityGrid
|
||||||
entity-type="people"
|
entity-type="people"
|
||||||
:entities="people"
|
:entities="people"
|
||||||
:max-items="5"
|
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="people"
|
:all-contacts="people"
|
||||||
@@ -39,7 +38,6 @@
|
|||||||
<EntityGrid
|
<EntityGrid
|
||||||
entity-type="projects"
|
entity-type="projects"
|
||||||
:entities="projects"
|
:entities="projects"
|
||||||
:max-items="3"
|
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="people"
|
:all-contacts="people"
|
||||||
@@ -152,11 +150,8 @@ export default class EntityGridFunctionPropTest extends Vue {
|
|||||||
customPeopleFunction = (
|
customPeopleFunction = (
|
||||||
entities: Contact[],
|
entities: Contact[],
|
||||||
_entityType: string,
|
_entityType: string,
|
||||||
maxItems: number,
|
|
||||||
): Contact[] => {
|
): Contact[] => {
|
||||||
return entities
|
return entities.filter((person) => person.profileImageUrl);
|
||||||
.filter((person) => person.profileImageUrl)
|
|
||||||
.slice(0, maxItems);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,7 +160,6 @@ export default class EntityGridFunctionPropTest extends Vue {
|
|||||||
customProjectsFunction = (
|
customProjectsFunction = (
|
||||||
entities: PlanData[],
|
entities: PlanData[],
|
||||||
_entityType: string,
|
_entityType: string,
|
||||||
_maxItems: number,
|
|
||||||
): PlanData[] => {
|
): PlanData[] => {
|
||||||
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
|
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
|
||||||
};
|
};
|
||||||
@@ -200,16 +194,16 @@ export default class EntityGridFunctionPropTest extends Vue {
|
|||||||
*/
|
*/
|
||||||
get displayedPeopleCount(): number {
|
get displayedPeopleCount(): number {
|
||||||
if (this.useCustomFunction) {
|
if (this.useCustomFunction) {
|
||||||
return this.customPeopleFunction(this.people, "people", 5).length;
|
return this.customPeopleFunction(this.people, "people").length;
|
||||||
}
|
}
|
||||||
return Math.min(5, this.people.length);
|
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayedProjectsCount(): number {
|
get displayedProjectsCount(): number {
|
||||||
if (this.useCustomFunction) {
|
if (this.useCustomFunction) {
|
||||||
return this.customProjectsFunction(this.projects, "projects", 3).length;
|
return this.customProjectsFunction(this.projects, "projects").length;
|
||||||
}
|
}
|
||||||
return Math.min(7, this.projects.length);
|
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -970,6 +970,20 @@ export const PlatformServiceMixin = {
|
|||||||
return this.$normalizeContacts(rawContacts);
|
return this.$normalizeContacts(rawContacts);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all contacts sorted by when they were added (by ID)
|
||||||
|
* Always fetches fresh data from database for consistency
|
||||||
|
* Handles JSON string/object duality for contactMethods field
|
||||||
|
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
|
||||||
|
*/
|
||||||
|
async $contactsByDateAdded(): Promise<Contact[]> {
|
||||||
|
const rawContacts = (await this.$query(
|
||||||
|
"SELECT * FROM contacts ORDER BY id DESC",
|
||||||
|
)) as ContactMaybeWithJsonStrings[];
|
||||||
|
|
||||||
|
return this.$normalizeContacts(rawContacts);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ultra-concise shortcut for getting number of contacts
|
* Ultra-concise shortcut for getting number of contacts
|
||||||
* @returns Promise<number> Total number of contacts
|
* @returns Promise<number> Total number of contacts
|
||||||
@@ -1353,6 +1367,9 @@ export const PlatformServiceMixin = {
|
|||||||
contact.profileImageUrl !== undefined
|
contact.profileImageUrl !== undefined
|
||||||
? contact.profileImageUrl
|
? contact.profileImageUrl
|
||||||
: null,
|
: null,
|
||||||
|
notes: contact.notes !== undefined ? contact.notes : null,
|
||||||
|
iViewContent:
|
||||||
|
contact.iViewContent !== undefined ? contact.iViewContent : null,
|
||||||
contactMethods:
|
contactMethods:
|
||||||
contact.contactMethods !== undefined
|
contact.contactMethods !== undefined
|
||||||
? Array.isArray(contact.contactMethods)
|
? Array.isArray(contact.contactMethods)
|
||||||
@@ -1363,8 +1380,8 @@ export const PlatformServiceMixin = {
|
|||||||
|
|
||||||
await this.$dbExec(
|
await this.$dbExec(
|
||||||
`INSERT OR REPLACE INTO contacts
|
`INSERT OR REPLACE INTO contacts
|
||||||
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods)
|
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
safeContact.did,
|
safeContact.did,
|
||||||
safeContact.name,
|
safeContact.name,
|
||||||
@@ -1373,6 +1390,8 @@ export const PlatformServiceMixin = {
|
|||||||
safeContact.registered,
|
safeContact.registered,
|
||||||
safeContact.nextPubKeyHashB64,
|
safeContact.nextPubKeyHashB64,
|
||||||
safeContact.profileImageUrl,
|
safeContact.profileImageUrl,
|
||||||
|
safeContact.notes,
|
||||||
|
safeContact.iViewContent,
|
||||||
safeContact.contactMethods,
|
safeContact.contactMethods,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -2057,6 +2076,7 @@ declare module "@vue/runtime-core" {
|
|||||||
|
|
||||||
// Specialized shortcuts - contacts cached, settings fresh
|
// Specialized shortcuts - contacts cached, settings fresh
|
||||||
$contacts(): Promise<Contact[]>;
|
$contacts(): Promise<Contact[]>;
|
||||||
|
$contactsByDateAdded(): Promise<Contact[]>;
|
||||||
$contactCount(): Promise<number>;
|
$contactCount(): Promise<number>;
|
||||||
$settings(defaults?: Settings): Promise<Settings>;
|
$settings(defaults?: Settings): Promise<Settings>;
|
||||||
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;
|
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;
|
||||||
|
|||||||
@@ -375,45 +375,6 @@
|
|||||||
Switch Identifier
|
Switch Identifier
|
||||||
</router-link>
|
</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
|
<label
|
||||||
for="toggleShowAmounts"
|
for="toggleShowAmounts"
|
||||||
class="flex items-center justify-between cursor-pointer my-4"
|
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 { ImportProgress } from "dexie-export-import";
|
||||||
import { LeafletMouseEvent } from "leaflet";
|
import { LeafletMouseEvent } from "leaflet";
|
||||||
import * as L from "leaflet";
|
import * as L from "leaflet";
|
||||||
import * as R from "ramda";
|
|
||||||
import { IIdentifier } from "@veramo/core";
|
import { IIdentifier } from "@veramo/core";
|
||||||
import { ref } from "vue";
|
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
import { copyToClipboard } from "../services/ClipboardService";
|
import { copyToClipboard } from "../services/ClipboardService";
|
||||||
@@ -799,7 +758,6 @@ import {
|
|||||||
NotificationIface,
|
NotificationIface,
|
||||||
PASSKEYS_ENABLED,
|
PASSKEYS_ENABLED,
|
||||||
} from "../constants/app";
|
} from "../constants/app";
|
||||||
import { Contact } from "../db/tables/contacts";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
|
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
|
||||||
BoundingBox,
|
BoundingBox,
|
||||||
@@ -823,11 +781,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
|||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||||
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||||||
import {
|
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
||||||
AccountSettings,
|
|
||||||
isApiError,
|
|
||||||
ImportContent,
|
|
||||||
} from "@/interfaces/accountView";
|
|
||||||
// Profile data interface (inlined from ProfileService)
|
// Profile data interface (inlined from ProfileService)
|
||||||
interface ProfileData {
|
interface ProfileData {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -836,8 +790,6 @@ interface ProfileData {
|
|||||||
includeLocation: boolean;
|
includeLocation: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputImportFileNameRef = ref<Blob>();
|
|
||||||
|
|
||||||
interface UserNameDialogRef {
|
interface UserNameDialogRef {
|
||||||
open: (cb: (name?: string) => void) => void;
|
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 {
|
private progressCallback(progress: ImportProgress): boolean {
|
||||||
logger.log(
|
logger.log(
|
||||||
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
|
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
|
||||||
|
|||||||
@@ -55,66 +55,70 @@
|
|||||||
|
|
||||||
<!-- Contact Methods -->
|
<!-- Contact Methods -->
|
||||||
<div class="mt-4">
|
<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="mt-4">
|
||||||
<div
|
<!-- Type and Value Row -->
|
||||||
v-for="(method, index) in contactMethods"
|
<div class="flex gap-2">
|
||||||
:key="index"
|
<div class="flex-none w-32">
|
||||||
class="flex mt-2"
|
<label class="block text-xs font-medium text-gray-700 mb-1">
|
||||||
>
|
Type
|
||||||
<input
|
</label>
|
||||||
v-model="method.label"
|
<select
|
||||||
type="text"
|
v-model="method.type"
|
||||||
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
|
class="block w-full 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')"
|
|
||||||
>
|
>
|
||||||
CELL
|
<option value="">—</option>
|
||||||
</div>
|
<option
|
||||||
<div
|
v-for="methodType in contactMethodTypes"
|
||||||
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
:key="methodType.value"
|
||||||
@click="setMethodType(index, 'EMAIL')"
|
:value="methodType.value"
|
||||||
>
|
>
|
||||||
EMAIL
|
{{ methodType.label }}
|
||||||
</div>
|
</option>
|
||||||
<div
|
</select>
|
||||||
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
|
||||||
@click="setMethodType(index, 'WHATSAPP')"
|
|
||||||
>
|
|
||||||
WHATSAPP
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
<button class="mt-2" @click="addContactMethod">
|
<button class="mt-4" @click="addContactMethod">
|
||||||
<font-awesome
|
<font-awesome
|
||||||
icon="plus"
|
icon="plus"
|
||||||
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
|
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
|
||||||
@@ -157,6 +161,7 @@ import {
|
|||||||
} from "../constants/notifications";
|
} from "../constants/notifications";
|
||||||
import { Contact, ContactMethod } from "../db/tables/contacts";
|
import { Contact, ContactMethod } from "../db/tables/contacts";
|
||||||
import { AppString } from "../constants/app";
|
import { AppString } from "../constants/app";
|
||||||
|
import { CONTACT_METHOD_TYPES } from "../constants/contacts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contact Edit View Component
|
* Contact Edit View Component
|
||||||
@@ -219,11 +224,11 @@ export default class ContactEditView extends Vue {
|
|||||||
contactNotes = "";
|
contactNotes = "";
|
||||||
/** Array of editable contact methods */
|
/** Array of editable contact methods */
|
||||||
contactMethods: Array<ContactMethod> = [];
|
contactMethods: Array<ContactMethod> = [];
|
||||||
/** Currently open dropdown index, null if none open */
|
|
||||||
dropdownIndex: number | null = null;
|
|
||||||
|
|
||||||
/** App string constants */
|
/** App string constants */
|
||||||
AppString = AppString;
|
AppString = AppString;
|
||||||
|
/** Contact method types for datalist suggestions */
|
||||||
|
contactMethodTypes = CONTACT_METHOD_TYPES;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component lifecycle hook that initializes the contact edit form
|
* Component lifecycle hook that initializes the contact edit form
|
||||||
@@ -280,29 +285,6 @@ export default class ContactEditView extends Vue {
|
|||||||
this.contactMethods.splice(index, 1);
|
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
|
* Saves the edited contact information to the database
|
||||||
*
|
*
|
||||||
@@ -338,9 +320,10 @@ export default class ContactEditView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to database via PlatformServiceMixin
|
// Save to database via PlatformServiceMixin
|
||||||
|
// Normalize empty strings to null to preserve database consistency
|
||||||
await this.$updateContact(this.contact?.did || "", {
|
await this.$updateContact(this.contact?.did || "", {
|
||||||
name: this.contactName,
|
name: this.contactName?.trim() || null,
|
||||||
notes: this.contactNotes,
|
notes: this.contactNotes?.trim() || null,
|
||||||
contactMethods: contactMethods,
|
contactMethods: contactMethods,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Help 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"
|
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]" />
|
<font-awesome icon="question" class="block text-center w-[1em]" />
|
||||||
</button>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Identity Details -->
|
<!-- Identity Details -->
|
||||||
@@ -42,6 +42,58 @@
|
|||||||
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</h2>
|
</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">
|
<button class="ml-2 mr-2 mt-4" @click="toggleDidDetails">
|
||||||
Details
|
Details
|
||||||
<font-awesome
|
<font-awesome
|
||||||
@@ -302,6 +354,7 @@ import {
|
|||||||
NOTIFY_CONTACT_INVALID_DID,
|
NOTIFY_CONTACT_INVALID_DID,
|
||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
|
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
|
||||||
|
import { getContactMethodLabel } from "@/constants/contacts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DIDView Component
|
* DIDView Component
|
||||||
@@ -352,6 +405,7 @@ export default class DIDView extends Vue {
|
|||||||
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
||||||
didInfoForContact = didInfoForContact;
|
didInfoForContact = didInfoForContact;
|
||||||
displayAmount = displayAmount;
|
displayAmount = displayAmount;
|
||||||
|
getContactMethodLabel = getContactMethodLabel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes notification helpers
|
* Initializes notification helpers
|
||||||
|
|||||||
@@ -898,7 +898,13 @@ export default class HomeView extends Vue {
|
|||||||
this.starredPlanHandleIds,
|
this.starredPlanHandleIds,
|
||||||
this.lastAckedStarredPlanChangesJwtId,
|
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;
|
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't show errors for starred project changes as it's a secondary feature
|
// Don't show errors for starred project changes as it's a secondary feature
|
||||||
|
|||||||
@@ -284,7 +284,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- New line that appears on hover -->
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
|
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) {
|
for (const planChange of planChanges) {
|
||||||
const currentPlan: PlanSummaryRecord = planChange.plan;
|
const currentPlan: PlanSummaryRecord = planChange.plan;
|
||||||
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
|
const wrappedClaim: GenericCredWrapper<PlanActionClaim> | undefined =
|
||||||
planChange.wrappedClaimBefore;
|
planChange.wrappedClaimBefore;
|
||||||
|
|
||||||
// Extract the actual claim from the wrapped claim
|
// 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 (
|
if (
|
||||||
embeddedClaim &&
|
embeddedClaim &&
|
||||||
typeof embeddedClaim === "object" &&
|
typeof embeddedClaim === "object" &&
|
||||||
@@ -609,7 +612,9 @@ export default class NewActivityView extends Vue {
|
|||||||
previousClaim = embeddedClaim;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,60 @@
|
|||||||
</div>
|
</div>
|
||||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||||
|
|
||||||
<input
|
<!-- Authorized Representative Selection -->
|
||||||
v-model="agentDid"
|
<div class="w-full flex items-stretch my-4">
|
||||||
type="text"
|
<div
|
||||||
placeholder="Other Authorized Representative"
|
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"
|
||||||
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
|
@click="openRepresentativeDialog"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<EntityIcon
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
:contact="selectedRepresentative"
|
||||||
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
|
/>
|
||||||
|
<font-awesome v-else icon="user" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'text-sm font-semibold': selectedRepresentative,
|
||||||
|
'text-slate-400': !selectedRepresentative,
|
||||||
|
}"
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
selectedRepresentative
|
||||||
|
? selectedRepresentative.name || AppString.NO_CONTACT_NAME
|
||||||
|
: "Assign Authorized Representative…"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
class="text-xs text-slate-500 truncate"
|
||||||
|
>
|
||||||
|
{{ agentDid }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
||||||
|
@click="unsetRepresentative"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjectRepresentativeDialog
|
||||||
|
ref="representativeDialog"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:notify="$notify"
|
||||||
|
@assign="handleRepresentativeAssigned"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p v-if="shouldShowOwnershipWarning">
|
<p v-if="shouldShowOwnershipWarning">
|
||||||
<span class="text-red-500">Beware!</span>
|
<span class="text-red-500">Beware!</span>
|
||||||
@@ -232,9 +280,12 @@ import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
|||||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
import { LeafletMouseEvent } from "leaflet";
|
import { LeafletMouseEvent } from "leaflet";
|
||||||
|
|
||||||
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||||
|
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import {
|
import {
|
||||||
|
AppString,
|
||||||
DEFAULT_IMAGE_API_SERVER,
|
DEFAULT_IMAGE_API_SERVER,
|
||||||
DEFAULT_PARTNER_API_SERVER,
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
@@ -268,6 +319,7 @@ import {
|
|||||||
retrieveAccountCount,
|
retrieveAccountCount,
|
||||||
retrieveFullyDecryptedAccount,
|
retrieveFullyDecryptedAccount,
|
||||||
} from "../libs/util";
|
} from "../libs/util";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EventTemplate,
|
EventTemplate,
|
||||||
@@ -323,7 +375,15 @@ import { logger } from "../utils/logger";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
|
components: {
|
||||||
|
EntityIcon,
|
||||||
|
ImageMethodDialog,
|
||||||
|
ProjectRepresentativeDialog,
|
||||||
|
LMap,
|
||||||
|
LMarker,
|
||||||
|
LTileLayer,
|
||||||
|
QuickNav,
|
||||||
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class NewEditProjectView extends Vue {
|
export default class NewEditProjectView extends Vue {
|
||||||
@@ -334,6 +394,9 @@ export default class NewEditProjectView extends Vue {
|
|||||||
// Notification helpers
|
// Notification helpers
|
||||||
private notify!: ReturnType<typeof createNotifyHelpers>;
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
AppString = AppString;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display error notification to user
|
* Display error notification to user
|
||||||
* Provides consistent error messaging with 5-second timeout
|
* Provides consistent error messaging with 5-second timeout
|
||||||
@@ -346,6 +409,8 @@ export default class NewEditProjectView extends Vue {
|
|||||||
// Component state properties
|
// Component state properties
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
agentDid = "";
|
agentDid = "";
|
||||||
|
allContacts: Array<Contact> = [];
|
||||||
|
allMyDids: string[] = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
endDateInput?: string;
|
endDateInput?: string;
|
||||||
endTimeInput?: string;
|
endTimeInput?: string;
|
||||||
@@ -392,16 +457,24 @@ export default class NewEditProjectView extends Vue {
|
|||||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
|
||||||
|
// Get all user's DIDs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allMyDids = await (this as any).$getAllAccountDids();
|
||||||
|
|
||||||
|
// Load contacts sorted by date added (newest first) for consistent "Recently Added" display
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allContacts = await (this as any).$contactsByDateAdded();
|
||||||
|
|
||||||
this.apiServer = settings.apiServer || "";
|
this.apiServer = settings.apiServer || "";
|
||||||
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||||
|
|
||||||
this.projectId = (this.$route.query["projectId"] as string) || "";
|
this.projectId = (this.$route.query["projectId"] as string) || "";
|
||||||
|
|
||||||
if (this.projectId) {
|
if (this.isSavedProject()) {
|
||||||
if (this.numAccounts === 0) {
|
if (this.numAccounts === 0) {
|
||||||
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
|
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
|
||||||
} else {
|
} else {
|
||||||
this.loadProject(this.activeDid);
|
this.loadProject(this.activeDid, this.projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,11 +484,9 @@ export default class NewEditProjectView extends Vue {
|
|||||||
* Retrieves project information from the API and populates form fields
|
* Retrieves project information from the API and populates form fields
|
||||||
* @param userDid - User's decentralized identifier
|
* @param userDid - User's decentralized identifier
|
||||||
*/
|
*/
|
||||||
async loadProject(userDid: string) {
|
async loadProject(userDid: string, projectId: string) {
|
||||||
const url =
|
const url =
|
||||||
this.apiServer +
|
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
||||||
"/api/claim/byHandle/" +
|
|
||||||
encodeURIComponent(this.projectId);
|
|
||||||
const headers = await getHeaders(userDid);
|
const headers = await getHeaders(userDid);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -432,6 +503,12 @@ export default class NewEditProjectView extends Vue {
|
|||||||
}
|
}
|
||||||
if (this.fullClaim?.agent?.identifier) {
|
if (this.fullClaim?.agent?.identifier) {
|
||||||
this.agentDid = this.fullClaim.agent.identifier;
|
this.agentDid = this.fullClaim.agent.identifier;
|
||||||
|
if (this.activeDid !== this.projectIssuerDid) {
|
||||||
|
this.agentDid = this.projectIssuerDid;
|
||||||
|
this.notify.warning(
|
||||||
|
"You were previously the agent, so the agent has been set to the previous owner. You can change it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.fullClaim.startTime) {
|
if (this.fullClaim.startTime) {
|
||||||
const localDateTime = DateTime.fromISO(
|
const localDateTime = DateTime.fromISO(
|
||||||
@@ -536,7 +613,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
private async saveProject() {
|
private async saveProject() {
|
||||||
// Make a claim
|
// Make a claim
|
||||||
const vcClaim: PlanActionClaim = this.fullClaim;
|
const vcClaim: PlanActionClaim = this.fullClaim;
|
||||||
if (this.projectId) {
|
if (this.isSavedProject()) {
|
||||||
vcClaim.lastClaimId = this.lastClaimJwtId;
|
vcClaim.lastClaimId = this.lastClaimJwtId;
|
||||||
}
|
}
|
||||||
if (this.agentDid) {
|
if (this.agentDid) {
|
||||||
@@ -870,6 +947,10 @@ export default class NewEditProjectView extends Vue {
|
|||||||
this.longitude = event.latlng.lng;
|
this.longitude = event.latlng.lng;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSavedProject(): boolean {
|
||||||
|
return !!this.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property for character count display
|
* Computed property for character count display
|
||||||
* Shows current description length and maximum character limit
|
* Shows current description length and maximum character limit
|
||||||
@@ -885,6 +966,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
*/
|
*/
|
||||||
get shouldShowOwnershipWarning(): boolean {
|
get shouldShowOwnershipWarning(): boolean {
|
||||||
return (
|
return (
|
||||||
|
this.isSavedProject() &&
|
||||||
this.activeDid !== this.projectIssuerDid &&
|
this.activeDid !== this.projectIssuerDid &&
|
||||||
this.agentDid !== this.projectIssuerDid
|
this.agentDid !== this.projectIssuerDid
|
||||||
);
|
);
|
||||||
@@ -961,5 +1043,37 @@ export default class NewEditProjectView extends Vue {
|
|||||||
get shouldShowSpinner(): boolean {
|
get shouldShowSpinner(): boolean {
|
||||||
return !this.isHiddenSpinner;
|
return !this.isHiddenSpinner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected representative contact
|
||||||
|
* Derives the contact from agentDid by finding it in allContacts
|
||||||
|
*/
|
||||||
|
get selectedRepresentative(): Contact | null {
|
||||||
|
if (!this.agentDid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.allContacts.find((c) => c.did === this.agentDid) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the representative selection dialog
|
||||||
|
*/
|
||||||
|
openRepresentativeDialog(): void {
|
||||||
|
(this.$refs.representativeDialog as ProjectRepresentativeDialog).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle representative assignment from dialog
|
||||||
|
*/
|
||||||
|
handleRepresentativeAssigned(contact: Contact): void {
|
||||||
|
this.agentDid = contact.did;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unset the representative and revert to initial state
|
||||||
|
*/
|
||||||
|
unsetRepresentative(): void {
|
||||||
|
this.agentDid = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -186,16 +186,59 @@
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="projectLink"
|
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
|
>Project Link</label
|
||||||
>
|
>
|
||||||
<input
|
<div class="w-full flex items-stretch">
|
||||||
id="projectLink"
|
<div
|
||||||
v-model="newOrUpdatedMeetingInputs.projectLink"
|
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"
|
||||||
type="text"
|
@click="openProjectLinkDialog"
|
||||||
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>
|
||||||
/>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -224,6 +267,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MeetingProjectDialog
|
||||||
|
ref="meetingProjectDialog"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:notify="$notify"
|
||||||
|
@assign="handleProjectLinkAssigned"
|
||||||
|
@open="handleDialogOpen"
|
||||||
|
@close="handleDialogClose"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Members Section -->
|
<!-- Members Section -->
|
||||||
<div
|
<div
|
||||||
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
||||||
@@ -254,6 +308,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<MembersList
|
<MembersList
|
||||||
|
ref="membersList"
|
||||||
:password="currentMeeting.password || ''"
|
:password="currentMeeting.password || ''"
|
||||||
:show-organizer-tools="true"
|
:show-organizer-tools="true"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@@ -292,10 +347,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import MembersList from "../components/MembersList.vue";
|
import MembersList from "../components/MembersList.vue";
|
||||||
|
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
||||||
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
import {
|
import {
|
||||||
errorStringForLog,
|
errorStringForLog,
|
||||||
getHeaders,
|
getHeaders,
|
||||||
serverMessageForUser,
|
serverMessageForUser,
|
||||||
|
didInfo,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import { encryptMessage } from "../libs/crypto";
|
import { encryptMessage } from "../libs/crypto";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
@@ -309,6 +367,8 @@ import {
|
|||||||
NOTIFY_MEETING_DELETED,
|
NOTIFY_MEETING_DELETED,
|
||||||
NOTIFY_MEETING_LINK_COPIED,
|
NOTIFY_MEETING_LINK_COPIED,
|
||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
|
import { PlanData } from "../interfaces/records";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
interface ServerMeeting {
|
interface ServerMeeting {
|
||||||
groupId: number; // from the server
|
groupId: number; // from the server
|
||||||
name: string; // to & from the server
|
name: string; // to & from the server
|
||||||
@@ -331,6 +391,8 @@ interface MeetingSetupInputs {
|
|||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
MembersList,
|
MembersList,
|
||||||
|
MeetingProjectDialog,
|
||||||
|
ProjectIcon,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
@@ -354,6 +416,9 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
showDeleteConfirm = false;
|
showDeleteConfirm = false;
|
||||||
fullName = "";
|
fullName = "";
|
||||||
|
allContacts: Contact[] = [];
|
||||||
|
allMyDids: string[] = [];
|
||||||
|
selectedProjectData: PlanData | null = null;
|
||||||
get minDateTime() {
|
get minDateTime() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
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.fullName = settings?.firstName || "";
|
||||||
this.isRegistered = !!settings?.isRegistered;
|
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();
|
await this.fetchCurrentMeeting();
|
||||||
|
|
||||||
|
// Ensure selected project is loaded if projectLink exists
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
|
||||||
this.isLoading = false;
|
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() {
|
async createMeeting() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
@@ -576,7 +699,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startEditing() {
|
async startEditing() {
|
||||||
// Populate form with existing meeting data
|
// Populate form with existing meeting data
|
||||||
if (this.currentMeeting) {
|
if (this.currentMeeting) {
|
||||||
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||||
@@ -587,6 +710,10 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
password: this.currentMeeting.password || "",
|
password: this.currentMeeting.password || "",
|
||||||
projectLink: this.currentMeeting.projectLink || "",
|
projectLink: this.currentMeeting.projectLink || "",
|
||||||
};
|
};
|
||||||
|
// Ensure selected project is loaded if projectLink exists
|
||||||
|
if (this.currentMeeting.projectLink) {
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.$logError(
|
this.$logError(
|
||||||
"There is no current meeting to edit. We should never get here.",
|
"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
|
// Reset form data
|
||||||
this.newOrUpdatedMeetingInputs = null;
|
this.newOrUpdatedMeetingInputs = null;
|
||||||
|
// Restore selected project from currentMeeting if it exists
|
||||||
|
if (this.currentMeeting?.projectLink) {
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
} else {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMeeting() {
|
async updateMeeting() {
|
||||||
@@ -710,5 +843,78 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.notify.error("Failed to copy meeting link to clipboard.");
|
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>
|
</script>
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
<button :class="sqlLinkClasses" @click="setAccountsQuery">
|
<button :class="sqlLinkClasses" @click="setAccountsQuery">
|
||||||
Accounts
|
Accounts
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="sqlLinkClasses" @click="setActiveIdentityQuery">
|
||||||
|
Active DID
|
||||||
|
</button>
|
||||||
<button :class="sqlLinkClasses" @click="setContactsQuery">
|
<button :class="sqlLinkClasses" @click="setContactsQuery">
|
||||||
Contacts
|
Contacts
|
||||||
</button>
|
</button>
|
||||||
@@ -525,6 +528,11 @@ export default class Help extends Vue {
|
|||||||
this.executeSql();
|
this.executeSql();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActiveIdentityQuery() {
|
||||||
|
this.sqlQuery = "SELECT * FROM active_identity;";
|
||||||
|
this.executeSql();
|
||||||
|
}
|
||||||
|
|
||||||
setContactsQuery() {
|
setContactsQuery() {
|
||||||
this.sqlQuery = "SELECT * FROM contacts;";
|
this.sqlQuery = "SELECT * FROM contacts;";
|
||||||
this.executeSql();
|
this.executeSql();
|
||||||
|
|||||||
@@ -54,6 +54,108 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Map for first coordinates -->
|
||||||
<div v-if="hasFirstLocation" class="mt-4">
|
<div v-if="hasFirstLocation" class="mt-4">
|
||||||
<h2 class="text-lg font-semibold">Location</h2>
|
<h2 class="text-lg font-semibold">Location</h2>
|
||||||
@@ -159,7 +261,11 @@ export default class UserProfileView extends Vue {
|
|||||||
activeDid = "";
|
activeDid = "";
|
||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
allMyDids: Array<string> = [];
|
allMyDids: Array<string> = [];
|
||||||
|
expandedNeighborDid: string | null = null;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
loadingNeighbors = false;
|
||||||
|
neighbors: Array<{ did: string; relation: string }> = [];
|
||||||
|
neighborsError = "";
|
||||||
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||||
profile: UserProfile | null = null;
|
profile: UserProfile | null = null;
|
||||||
|
|
||||||
@@ -183,8 +289,8 @@ export default class UserProfileView extends Vue {
|
|||||||
*/
|
*/
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.initializeSettings();
|
await this.initializeSettings();
|
||||||
await this.loadContacts();
|
|
||||||
await this.loadProfile();
|
await this.loadProfile();
|
||||||
|
await this.loadNeighbors();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,12 +305,7 @@ export default class UserProfileView extends Vue {
|
|||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
|
||||||
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads all contacts from database
|
|
||||||
*/
|
|
||||||
private async loadContacts() {
|
|
||||||
this.allContacts = await this.$getAllContacts();
|
this.allContacts = await this.$getAllContacts();
|
||||||
this.allMyDids = await retrieveAccountDids();
|
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
|
* Fetches network connections for the profile and displays them
|
||||||
* Shows success notification when completed
|
* 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() {
|
async onCopyLinkClick() {
|
||||||
// Use production URL for sharing to avoid localhost issues in development
|
|
||||||
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(deepLink);
|
await copyToClipboard(deepLink);
|
||||||
this.notify.copied("profile link", TIMEOUTS.STANDARD);
|
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$logAndConsole(`Error copying profile link: ${error}`, true);
|
this.$logAndConsole(`Error copying profile link: ${error}`, true);
|
||||||
this.notify.error("Failed to copy profile link.");
|
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
|
* Computed properties for template logic streamlining
|
||||||
*/
|
*/
|
||||||
@@ -330,5 +508,64 @@ export default class UserProfileView extends Vue {
|
|||||||
get tileLayerUrl() {
|
get tileLayerUrl() {
|
||||||
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user