Compare commits

...

44 Commits

Author SHA1 Message Date
Jose Olarte III
6d221ee1ca Install optional iOS Dark/Tinted app icons after capacitor-assets generate.
Post-process AppIcon.appiconset so appearance variants are reapplied on every iOS build without changing the existing asset generation flow.
2026-07-16 18:34:22 +08:00
Jose Olarte III
823db447ca style(assets): refresh Giftopia icons and splashes with adaptive layers 2026-07-15 20:53:54 +08:00
87ffa025e8 bump version to 1.4.4 build 70 2026-06-22 12:14:29 -06:00
15c9088736 change the android build such that Play Store isn't required (also removed android/google-services.json) 2026-06-21 18:01:04 -06:00
ec41dd52d5 bump version to v 1.4.3 build 69 2026-06-21 10:59:24 -06:00
463db39a6b remove hard-coded daily android notification 2026-06-19 23:43:40 -06:00
fe97dff752 Merge pull request 'Rework Thanks Button' (#234) from thanks-button-rework into master
Reviewed-on: #234
2026-06-19 07:21:37 +00:00
Jose Olarte III
903047f13b style(gift): center entity selection step heading and entity type toggle 2026-06-18 21:09:32 +08:00
Jose Olarte III
48be234af4 fix(home): offset scrolled Thank button for safe-area-inset-bottom 2026-06-17 17:57:13 +08:00
6c0907d905 remove unused function & duplicate comment 2026-06-16 16:09:31 -06:00
Jose Olarte III
8d8bcf2a7e style(home): rework Thank button and sticky scroll action bar
Replace the floating circular plus FAB with a full-width bottom bar that
matches the inline Thank button. Wrap the quick-action section in a styled
container and raise the scroll threshold to 120px.
2026-06-16 21:48:34 +08:00
a4b47904c8 Merge pull request 'Add footer to Gifted Details view' (#233) from gifted-details-footer into master
Reviewed-on: #233
2026-06-16 08:27:47 +00:00
Jose Olarte III
bb890baacf fix(gifted-details): add QuickNav footer navigation 2026-06-15 17:20:18 +08:00
dae23300fe point to a single .entitlements file (undo most of previous commit) 2026-06-02 15:50:17 -06:00
9e401febea add 'share' to the entitlements for production, for sharing with this app 2026-06-02 15:46:36 -06:00
cd4b279703 Merge pull request '16kb-pages' (#232) from 16kb-pages into master
Reviewed-on: #232
2026-05-25 20:01:18 +00:00
a3a2d97b9a update version to v 1.4.2 build 68 2026-05-24 21:50:39 -06:00
802050259c update android build, fix ios build for new version of MLKit BarcodeScanner (both build) 2026-05-24 21:24:18 -06:00
efd7d50a84 fix build error 2026-05-24 19:12:40 -06:00
39c389cda8 make do-not-pair group verbiage more clear 2026-05-24 18:38:56 -06:00
93fdcaf7ff fix timing error for a click (that only showed in firefox) 2026-05-24 18:29:11 -06:00
ad419efa0d utilize 'userMessage' if sent by server 2026-05-24 16:23:47 -06:00
ce45ddb2bd update after 'audit fix' 2026-05-24 16:23:09 -06:00
7d306bd204 add first cut for 16kb page sizes, all by Claude 2026-05-10 10:15:10 -06:00
9713313a40 fix HTML syntax warning 2026-05-10 09:43:46 -06:00
Jose Olarte III
ffa7bac319 fix(ios): ensure capacitor-assets output dirs exist on fresh clones
Gitignored AppIcon.appiconset and Splash.imageset are absent after clone,
which made `capacitor-assets generate --ios` fail (missing paths and
Contents.json). Add ensure_ios_capacitor_asset_directories in common.sh
to mkdir and seed minimal Contents.json when needed; call it from
build-ios.sh before asset generation and from the build:native npm script.
Document the behavior in ios/.gitignore.
2026-04-13 16:20:51 +08:00
e0e0a0a183 bump version and add -beta 2026-04-05 20:08:24 -06:00
ea662f4430 bump to v 1.3.13 (for a web release) 2026-04-05 19:58:36 -06:00
81647e1f3c make terms & conditions into a separate page 2026-04-05 19:21:43 -06:00
bf1ee78025 allow a custom error message to stay on the screen indefinitely 2026-03-29 19:11:49 -06:00
Jose Olarte III
66b7d0f46e docs(readme): expand Setup & Building quick start for all platforms
Restructure the quick start with Web, Android, and iOS subheadings; put
each npm command in its own code block; fold the test-page step into the
Web section. Document Android (build:android:test:run + ADB, link to
BUILDING.md) and iOS (build:ios:studio + Xcode prerequisites).
2026-03-26 19:41:03 +08:00
Jose Olarte III
63dcf44125 fix(ios): make build-ios.sh work on current simulators and trim xcodebuild noise
Use generic/platform=iOS Simulator instead of a fixed device name so CLI builds
do not fail when that simulator is not installed (e.g. newer Xcode runtimes).

Pass -quiet to xcodebuild and enable SWIFT_SUPPRESS_WARNINGS plus
GCC_WARN_INHIBIT_ALL_WARNINGS for scripted builds and IPA archive/export so
terminal output stays smaller; full diagnostics remain available in Xcode.
2026-03-26 19:40:07 +08:00
cf1ecdfb4c add registration for new contacts that are unregistered 2026-03-22 20:20:33 -06:00
e9ad61b780 don't delete a gift image on an edit unless they hit 'save' 2026-03-22 20:07:59 -06:00
ad8df3eb93 fix problem where canceling an edit deletes an image 2026-03-22 20:06:58 -06:00
05d346edce add project selection for one that this 'fulfills' 2026-03-22 17:58:46 -06:00
e259e60fa7 bump version and add "-beta" 2026-03-22 17:39:46 -06:00
821de3f006 do not toggle off the 'advanced' section in account view with the 'general' toggle is disabled 2026-03-22 09:53:56 -06:00
43f83031d4 rename app from "Gifties" to "Giftopia" 2026-03-21 16:27:21 -06:00
688a48a332 bump to version 1.3.12 build 67 2026-03-21 16:22:14 -06:00
8938c242ee change more files to name the app "Gifties" 2026-03-20 19:33:04 -06:00
358af42afd rename from "Gift Economies" to "Gifties" 2026-03-19 21:18:11 -06:00
59c00241b8 add the nearest-neighbor feature to the claim screen 2026-03-19 20:24:09 -06:00
33ec90e571 move the 'discover' page 'starred' word to be on the same level 2026-03-18 19:44:25 -06:00
68 changed files with 6143 additions and 4558 deletions

View File

@@ -1 +1 @@
18.19.0
20.18.1

2
.nvmrc
View File

@@ -1 +1 @@
18.19.0
20.18.1

20
AGENTS.md Normal file
View File

@@ -0,0 +1,20 @@
# Agent Instructions for crowd-funder-for-time-pwa
## Android Build — Google Play Services / FOSS Compatibility
**Firebase is opt-in. Do NOT enable it accidentally.**
`android/google-services.json` is gitignored and may be present on disk for push notification development, but Firebase is only activated when you explicitly pass `-PfirebaseEnabled` to Gradle:
- **FOSS / APK / Aurora / Zapstore / F-Droid builds**: just `./gradlew assembleRelease` — Firebase stays off even if `google-services.json` is on disk.
- **Firebase / FCM / Play Store builds**: `./gradlew bundleRelease -PfirebaseEnabled` — explicitly opt in.
This guard is in `android/app/build.gradle`. Do NOT change this conditional to activate Firebase unconditionally based on file presence alone — that was the bug that broke FOSS distribution in June 2026.
`google-services.json` is intentionally excluded from git (`android/.gitignore`). Never commit it.
Full details, incident history, and F-Droid notes: `doc/development/android-firebase-gms.md`
## Android Build — MLKit Barcode Scanner
`@capacitor-mlkit/barcode-scanning` depends on `com.google.android.gms:play-services-code-scanner`, which merges `com.google.android.gms.version` into the APK manifest. This is a known long-term issue for strict FOSS/F-Droid builds. For now, the dependency is accepted; barcode scanning simply will not work on GMS-less devices (it fails gracefully at scan time, not at startup). Do not add additional GMS/Firebase dependencies without explicitly acknowledging this trade-off.

View File

@@ -333,11 +333,11 @@ The `serve` functionality provides a local HTTP server for testing production bu
- If there are DB changes: before updating the test server, open browser(s) with
current version to test DB migrations.
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run:
`npm install`.
- Run a build to make sure package-lock version is updated, linting works, etc:
`npm install && npm run build:web`
- Run a build to make sure linting works, etc:
`npm run build:web`
- Commit everything (since the commit hash is used the app).
@@ -346,7 +346,7 @@ current version to test DB migrations.
- Tag with the new version,
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
`git tag 1.0.2 && git push origin 1.0.2`.
`git tag 1.3.13 && git push origin 1.3.13`.
- For test, build the app:
@@ -1140,7 +1140,7 @@ export GEM_PATH=$shortened_path
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
cd ios/App && xcrun agvtool new-version 65 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.8;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 70 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.4;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1419,8 +1419,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 65/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.3.8"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 70/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.4"/g' android/app/build.gradle
```
##### 2. Build
@@ -1458,17 +1458,18 @@ cd -
- Setup by adding the app/gradle.properties.secrets file (see properties at top
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
- In app/build.gradle, bump the versionCode and maybe the versionName
- Then `bundleRelease`:
```bash
cd android
./gradlew bundleRelease -Dlint.baselines.continue=true
./gradlew bundleRelease -Dlint.baselines.continue=true -PfirebaseEnabled
cd -
```
... and find your `aab` file at app/build/outputs/bundle/release
* Note that F-Droid builds should omit `-PfirebaseEnabled`.
At play.google.com/console:
- Go to Production or the Closed Testing and either Create Track or Manage Track.

View File

@@ -6,9 +6,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.8] - 2026
## [1.4.4] - 2026.06.21
### Changed
- More checks for Firebase so that it won't break, eg in Aurora store.
## [1.4.3] - 2026.06.19
### Removed
- Automatic "Check your starred projects" daily notification
### Changed
- Positioning for 'Thank' button and entity-type toggle link
## [1.4.2] - 2026.05.24
### Changed
- Support 16 KB page sizes
## [1.3.13] - 2026.04.05
### Added
- Ability to select project that the current one fulfills
- Separate Terms & Conditions page (required for SMS campaigns)
### Fixed
- Edits to a 'give' would delete the image
## [1.3.12] - 2026.03.21
### Added
- Device wake-up for notifications
### Changed
- Rename to "Gifties"
## [1.3.7]

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -15,10 +15,31 @@ Quick start:
```bash
npm install
```
### Web
```bash
npm run build:web:dev
```
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
### Android
```bash
npm run build:android:test:run
```
Assumes ADB is installed; see [Android Build](BUILDING.md#android-build) for SDK, emulator, and `PATH` setup.
### iOS
```bash
npm run build:ios:studio
```
Assumes Xcode and Xcode Command Line Tools are installed.
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).

View File

@@ -29,16 +29,16 @@ android {
compileSdk rootProject.ext.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
defaultConfig {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 65
versionName "1.3.8"
versionCode 70
versionName "1.4.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -72,13 +72,14 @@ android {
}
packagingOptions {
jniLibs {
// Required for 16 KB page-size support: keep native libs uncompressed and
// page-aligned inside the APK (default on AGP 8.x with minSdk 23+, set
// explicitly so it does not regress).
useLegacyPackaging = false
pickFirsts += ['**/lib/x86_64/libbarhopper_v3.so', '**/lib/x86_64/libimage_processing_util_jni.so', '**/lib/x86_64/libsqlcipher.so']
}
}
// Configure for 16 KB page size compatibility
// Enable bundle builds (without which it doesn't work right for bundleDebug vs bundleRelease)
bundle {
language {
@@ -129,11 +130,20 @@ dependencies {
apply from: 'capacitor.build.gradle'
// Firebase / Google Play Services are opt-in. Pass -PfirebaseEnabled to any Gradle command
// to activate Firebase (FCM push notifications). Without this flag the build works on
// F-Droid, Aurora, Zapstore, and plain APK sideloading even when google-services.json
// is present on disk (it is gitignored; see AGENTS.md for the full story).
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
apply plugin: 'com.google.gms.google-services'
logger.info("Firebase enabled: google-services plugin applied")
} else if (servicesJSON.exists() && !project.hasProperty('firebaseEnabled')) {
logger.info("google-services.json present but firebaseEnabled not set — skipping Firebase plugin (pass -PfirebaseEnabled to enable)")
} else {
logger.info("google-services.json not found — Firebase plugin not applied")
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
logger.info("google-services plugin not applied: ${e.message}")
}

View File

@@ -2,8 +2,8 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}

View File

@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
},
@@ -100,7 +100,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},

View File

@@ -9,7 +9,6 @@ import org.timesafari.dailynotification.FetchContext;
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
import org.timesafari.dailynotification.NotificationContent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -47,22 +46,11 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
// This should query the TimeSafari API for notification content
// using the configured apiBaseUrl, activeDid, and jwtToken
// For now, return a placeholder notification
long scheduledTime = fetchContext.scheduledTime != null
? fetchContext.scheduledTime
: System.currentTimeMillis() + 60000; // 1 minute from now
NotificationContent content = new NotificationContent(
"TimeSafari Update",
"Check your starred projects for updates!",
scheduledTime
);
List<NotificationContent> results = new ArrayList<>();
results.add(content);
Log.d(TAG, "Returning " + results.size() + " notification(s)");
return results;
// Not implemented yet: return no content rather than fabricating a
// placeholder notification (previously hardcoded "Check your starred
// projects for updates!", which showed on every app startup).
Log.d(TAG, "Content fetching not yet implemented; returning no notifications");
return Collections.<NotificationContent>emptyList();
} catch (Exception e) {
Log.e(TAG, "Fetch failed", e);

View File

@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">TimeSafari</string>
<string name="title_activity_main">TimeSafari</string>
<string name="app_name">Giftopia</string>
<string name="title_activity_main">Giftopia</string>
<string name="package_name">timesafari.app</string>
<string name="custom_url_scheme">timesafari.app</string>
</resources>

View File

@@ -1,5 +1,5 @@
ext {
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
}
@@ -9,7 +9,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
classpath 'com.android.tools.build:gradle:8.7.2'
}
}
@@ -17,10 +17,10 @@ apply plugin: 'com.android.library'
android {
namespace "capacitor.cordova.android.plugins"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1
versionName "1.0"
}
@@ -28,8 +28,8 @@ android {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}

View File

@@ -1,6 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
ext {
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
// Plugin gradle extensions can append to this to have code run at the end.
cdvPluginPostBuildExtras = []
cordovaConfig = [:]

View File

@@ -13,4 +13,11 @@ ext {
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
// Pin CameraX to 1.4.2: first stable line shipping a 16 KB page-size-aligned
// libimage_processing_util_jni.so. The barcode-scanning plugin still defaults to 1.1.0.
androidxCameraCamera2Version = '1.4.2'
androidxCameraCoreVersion = '1.4.2'
androidxCameraLifecycleVersion = '1.4.2'
androidxCameraViewVersion = '1.4.2'
}

View File

@@ -2,9 +2,9 @@
"icon": {
"android": {
"adaptive": {
"background": "#121212",
"foreground": "resources/icon.png",
"monochrome": "resources/icon.png"
"background": "resources/android/icon/icon_background.png",
"foreground": "resources/android/icon/icon_foreground.png",
"monochrome": "resources/android/icon/icon_monochrome.png"
},
"target": "android/app/src/main/res"
},

View File

@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
}
@@ -73,7 +73,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},

View File

@@ -2,7 +2,7 @@ import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.timesafari',
appName: 'TimeSafari',
appName: 'Giftopia',
webDir: 'dist',
server: {
cleartext: true
@@ -36,12 +36,12 @@ const config: CapacitorConfig = {
iosIsEncryption: false,
iosBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
biometricTitle: 'Biometric login for Giftopia'
},
androidIsEncryption: false,
androidBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
biometricTitle: 'Biometric login for Giftopia'
},
electronIsEncryption: false
},
@@ -100,7 +100,7 @@ const config: CapacitorConfig = {
},
buildOptions: {
appId: 'app.timesafari',
productName: 'TimeSafari',
productName: 'Giftopia',
directories: {
output: 'dist-electron-packages'
},

View File

@@ -0,0 +1,66 @@
# Android — Firebase, Google Play Services, and FOSS Distribution
## Overview
The app is designed to work on Android devices with and without Google Play Services (GMS). Firebase/FCM push notifications are an opt-in feature at build time; all other functionality works on GMS-less devices (F-Droid, LineageOS without OpenGApps, etc.).
## How the opt-in guard works
`android/app/build.gradle` applies the `com.google.gms.google-services` Gradle plugin only when **both** conditions are true:
1. `android/google-services.json` is present on disk
2. The Gradle property `firebaseEnabled` is explicitly passed
```groovy
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
apply plugin: 'com.google.gms.google-services'
}
```
This means the file can live on disk for development purposes without accidentally activating Firebase.
## Build commands
| Target | Command | Firebase |
|---|---|---|
| APK / sideload / Zapstore | `./gradlew assembleRelease` | off |
| Aurora / Play Store without FCM | `./gradlew bundleRelease` | off |
| Play Store with FCM push notifications | `./gradlew bundleRelease -PfirebaseEnabled` | on |
| F-Droid | `./gradlew assembleRelease` | off (required) |
## Behavior on non-GMS devices
When built with `-PfirebaseEnabled`, Firebase SDKs check for GMS availability at startup and degrade gracefully if it is absent:
- Firebase initializes but detects no GMS
- FCM skips token registration silently (no token, no notifications)
- The app continues to work normally
This means a single Play Store AAB (`bundleRelease -PfirebaseEnabled`) covers both GMS and non-GMS users. GMS users get push notifications; non-GMS users get a fully functional app without them.
**Aurora Store** pulls the exact APK from Play Store servers, so Aurora users get whichever variant was uploaded. The Play Store AAB built with `-PfirebaseEnabled` is correct for Aurora.
**F-Droid** is stricter: their build policy rejects any APK with GMS dependencies at the binary level, even with graceful degradation. F-Droid submission would require a separate `assembleRelease` build (no flag) and a dedicated F-Droid listing.
## The `google-services.json` file
- Gitignored (`android/.gitignore` line 80) — never commit it
- Contains Firebase project credentials (project number, app ID, API key)
- Safe to leave on disk; has no effect unless `-PfirebaseEnabled` is passed
- Obtain from the Firebase console: Project Settings → Your apps → Android app → Download `google-services.json`
## Known GMS dependency: MLKit barcode scanner
`@capacitor-mlkit/barcode-scanning` unconditionally depends on `com.google.android.gms:play-services-code-scanner` (present since the plugin was first added at v6.0.0). This merges `com.google.android.gms.version` and `GoogleApiActivity` into the APK manifest regardless of the `-PfirebaseEnabled` flag.
Practical impact:
- **GMS devices**: barcode scanning works normally
- **Non-GMS devices**: barcode scanning fails at scan time (not at startup); the app launches and runs normally otherwise
This is an accepted trade-off. Removing it would require either forking the plugin or introducing a `foss` product flavor that excludes the MLKit plugin entirely — work to undertake if/when F-Droid submission is planned.
## Incident: June 2026
Jose Olarte III's `notify-api` branch placed a production `google-services.json` in `android/` to test Firebase Cloud Messaging. The branch was never merged to `master`, but because the file is gitignored it persisted on disk after switching branches. At the time, the Gradle conditional activated Firebase based on file presence alone (no opt-in flag), so all subsequent local builds embedded Firebase and required Google Play Services. This silently broke APK/Aurora/Zapstore distribution.
**Fix applied:** deleted `google-services.json` from disk, changed the Gradle conditional to require `-PfirebaseEnabled`, and documented the rule in `AGENTS.md`.

View File

@@ -0,0 +1,308 @@
# Deep Link Debugging Guide for TimeSafari
This guide helps you debug and fix deep link issues in the TimeSafari Capacitor application.
## Quick Start
1. **Check logs**: Use browser dev tools or device console to see detailed logging
2. **Test manually**: Use the testing script or browser console commands
3. **Verify configuration**: Ensure all platform configurations are correct
## Common Issues and Solutions
### Issue 1: App opens but doesn't navigate to the correct page
**Symptoms:**
- Deep link opens the app
- App stays on home screen or current page
- No error messages visible
**Debugging Steps:**
1. **Check console logs** in browser dev tools or device console:
```bash
# Android
adb logcat | grep -E "(TimeSafari|DeepLink|appUrlOpen)"
# iOS Simulator
xcrun simctl spawn booted log stream --predicate 'process == "TimeSafari"'
```
2. **Verify listener registration**:
Look for these log messages:
```
[DeepLink] Registering appUrlOpen listener...
[DeepLink] Listener registered successfully
```
3. **Check for event reception**:
Look for:
```
[DeepLink] ========== DEEP LINK EVENT RECEIVED ==========
[DeepLink] URL: timesafari://your/url/here
```
4. **Verify URL parsing**:
Check if URL components are parsed correctly:
```
[DeepLinkHandler.parseDeepLink] Parse result: {"path":"claim","params":{"id":"123"},"query":{}}
```
### Issue 2: Listener not receiving events
**Symptoms:**
- No deep link logs appear
- App opens but no event processing
**Solutions:**
1. **Rebuild and reinstall** the app completely:
```bash
npm run build:capacitor
npx cap sync
npx cap run android # or ios
```
2. **Check capacitor.config.json**:
```json
{
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
}
}
}
```
3. **Verify native configuration**:
- **Android**: Check `android/app/src/main/AndroidManifest.xml`
- **iOS**: Check `ios/App/App/Info.plist`
### Issue 3: URL scheme not recognized by OS
**Symptoms:**
- "No app found to handle this link" error
- OS doesn't open your app
**Solutions:**
1. **Android**: Verify intent filter in AndroidManifest.xml:
```xml
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
```
2. **iOS**: Verify URL types in Info.plist:
```xml
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
```
## Testing Tools
### 1. Automated Testing Script
Use the provided testing script:
```bash
# Test all URLs on Android
./scripts/test-deep-links.sh android
# Test specific URL on iOS
./scripts/test-deep-links.sh ios "timesafari://claim/test123"
# Monitor logs
./scripts/test-deep-links.sh android-logs
```
### 2. Manual Testing Commands
**Android Emulator:**
```bash
adb shell am start -W -a android.intent.action.VIEW -d "timesafari://claim/test123" app.timesafari
```
**iOS Simulator:**
```bash
xcrun simctl openurl booted "timesafari://claim/test123"
```
### 3. Browser Console Testing
For web/PWA testing, use the browser console:
```javascript
// Test deep link processing directly
window.testSingleDeepLink("timesafari://claim/test123");
// Run all test URLs
window.testDeepLinks();
```
## Debugging Steps Checklist
### Pre-Testing Setup
- [ ] App is installed on device/emulator
- [ ] App has been launched at least once
- [ ] Device/emulator is properly connected
- [ ] Debugging tools are accessible
### During Testing
- [ ] Check console for initialization logs
- [ ] Verify listener registration
- [ ] Test with simple URL first (e.g., `timesafari://claim/test`)
- [ ] Monitor URL parsing logs
- [ ] Check router navigation logs
### Post-Testing Analysis
- [ ] Review complete log sequence
- [ ] Identify where process fails
- [ ] Check error messages for clues
- [ ] Test with different URL formats
## Common Log Patterns
### Successful Deep Link Flow
```
[DeepLink] Registering appUrlOpen listener...
[DeepLink] Listener registered successfully
[DeepLink] ========== DEEP LINK EVENT RECEIVED ==========
[DeepLink] URL: timesafari://claim/test123
[DeepLinkHandler] Starting handleDeepLink with URL: timesafari://claim/test123
[DeepLinkHandler.parseDeepLink] Parse result: {"path":"claim","params":{"id":"test123"},"query":{}}
[DeepLinkHandler.validateAndRoute] Route validation passed. Route name: claim
[DeepLinkHandler.validateAndRoute] Router navigation completed successfully
[DeepLink] Deep link handled successfully
```
### Failed URL Parsing
```
[DeepLinkHandler.parseDeepLink] Route not found: invalid-route
[DeepLinkHandler.parseDeepLink] Available routes: ["claim","project","contact-import",...]
[DeepLinkHandler.validateAndRoute] Redirecting to deep-link-error page
```
### Router Navigation Issues
```
[DeepLinkHandler.validateAndRoute] Error routing to route name claim
[DeepLinkHandler.validateAndRoute] Navigation params: {"name":"claim","params":{"id":"test123"}}
```
## Platform-Specific Issues
### Android
**Issue**: Deep links work in development but not in production build
- **Solution**: Ensure `android:exported="true"` in MainActivity
**Issue**: App doesn't respond to links when running in background
- **Solution**: Check `android:launchMode="singleTask"` in AndroidManifest.xml
### iOS
**Issue**: Deep links don't work in iOS simulator
- **Solution**: Use `xcrun simctl openurl` instead of opening URLs in Safari
**Issue**: App launches but doesn't process URL
- **Solution**: Check for Associated Domains if using universal links
## Advanced Debugging
### Enable Capacitor Native Logging
Add to `capacitor.config.json`:
```json
{
"ios": {
"loggingBehavior": "debug"
},
"android": {
"loggingBehavior": "debug"
}
}
```
### Add Custom Debug Points
Insert additional logging in your deep link handler:
```typescript
// Add at strategic points in DeepLinkHandler
console.log('[DEBUG] Custom checkpoint:', { data: yourData });
```
### Network Debugging
If deep links involve network requests:
```bash
# Monitor network traffic (Android)
adb shell dumpsys connectivity
# Monitor network traffic (iOS)
# Use Xcode Network Debugger
```
## Recovery Strategies
### If deep links stop working completely:
1. **Clean rebuild**:
```bash
rm -rf node_modules
npm install
npm run build:capacitor
npx cap sync
```
2. **Reset device/emulator**:
- Clear app data
- Uninstall and reinstall
- Restart emulator
3. **Verify basic functionality**:
- Test simple navigation within app
- Test URL schemes with minimal URLs
- Gradually increase complexity
## Support Resources
- [Capacitor Deep Links Documentation](https://capacitorjs.com/docs/guides/deep-links)
- [Android Intent Filter Guide](https://developer.android.com/guide/components/intents-filters)
- [iOS URL Scheme Guide](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app)
## Contact and Feedback
If you encounter issues not covered in this guide:
1. Check the project's issue tracker
2. Review recent commits for deep link changes
3. Test with minimal reproduction case
4. Document exact steps and environment details

View File

@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
}
@@ -72,7 +72,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},

3
ios/.gitignore vendored
View File

@@ -17,6 +17,7 @@ App/App/config.xml
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
# Generated by capacitor-assets at build time (not in repo). Fresh clones lack these
# folders; scripts/common.sh ensure_ios_capacitor_asset_directories creates them before generate.
App/App/Assets.xcassets/AppIcon.appiconset
App/App/Assets.xcassets/Splash.imageset

View File

@@ -452,7 +452,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -508,7 +508,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -524,17 +524,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -552,17 +553,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -580,12 +582,12 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -594,7 +596,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -618,12 +620,12 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -632,7 +634,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>TimeSafari</string>
<string>Giftopia</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>

View File

@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
platform :ios, '15.5'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -30,9 +30,4 @@ end
post_install do |installer|
assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
end
end
end

View File

@@ -1,94 +1,84 @@
PODS:
- Capacitor (6.2.1):
- Capacitor (7.6.4):
- CapacitorCordova
- CapacitorApp (6.0.2):
- CapacitorApp (7.1.2):
- Capacitor
- CapacitorCamera (6.1.2):
- CapacitorCamera (7.0.5):
- Capacitor
- CapacitorClipboard (6.0.2):
- CapacitorClipboard (7.0.4):
- Capacitor
- CapacitorCommunitySqlite (6.0.2):
- CapacitorCommunitySqlite (7.0.3):
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (6.2.1)
- CapacitorFilesystem (6.0.3):
- CapacitorCordova (7.6.4)
- CapacitorFilesystem (7.1.8):
- Capacitor
- CapacitorMlkitBarcodeScanning (6.2.0):
- IONFilesystemLib (~> 1.1.1)
- CapacitorMlkitBarcodeScanning (7.5.0):
- Capacitor
- GoogleMLKit/BarcodeScanning (= 5.0.0)
- CapacitorShare (6.0.3):
- GoogleMLKit/BarcodeScanning (= 7.0.0)
- CapacitorShare (7.0.4):
- Capacitor
- CapacitorStatusBar (6.0.2):
- CapacitorStatusBar (7.0.6):
- Capacitor
- CapawesomeCapacitorFilePicker (6.2.0):
- CapawesomeCapacitorFilePicker (7.2.0):
- Capacitor
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleMLKit/BarcodeScanning (5.0.0):
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleMLKit/BarcodeScanning (7.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 4.0.0)
- GoogleMLKit/MLKitCore (5.0.0):
- MLKitCommon (~> 10.0.0)
- GoogleToolboxForMac/DebugUtils (2.3.2):
- GoogleToolboxForMac/Defines (= 2.3.2)
- GoogleToolboxForMac/Defines (2.3.2)
- GoogleToolboxForMac/Logger (2.3.2):
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSData+zlib (2.3.2)":
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)":
- GoogleToolboxForMac/DebugUtils (= 2.3.2)
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)"
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)"
- GoogleUtilities/Environment (7.13.3):
- MLKitBarcodeScanning (~> 6.0.0)
- GoogleMLKit/MLKitCore (7.0.0):
- MLKitCommon (~> 12.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3)
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilitiesComponents (1.1.0):
- GoogleUtilities/Logger
- GTMSessionFetcher/Core (3.5.0)
- MLImage (1.0.0-beta5)
- MLKitBarcodeScanning (4.0.0):
- MLKitCommon (~> 10.0)
- MLKitVision (~> 6.0)
- MLKitCommon (10.0.0):
- GoogleDataTransport (~> 9.0)
- GoogleToolboxForMac/Logger (~> 2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
- GoogleUtilities/UserDefaults (~> 7.0)
- GoogleUtilitiesComponents (~> 1.0)
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- MLKitVision (6.0.0):
- GoogleToolboxForMac/Logger (~> 2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- MLImage (= 1.0.0-beta5)
- MLKitCommon (~> 10.0)
- nanopb (2.30910.0):
- nanopb/decode (= 2.30910.0)
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- IONFilesystemLib (1.1.2)
- MLImage (1.0.0-beta6)
- MLKitBarcodeScanning (6.0.0):
- MLKitCommon (~> 12.0)
- MLKitVision (~> 8.0)
- MLKitCommon (12.0.0):
- GoogleDataTransport (~> 10.0)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/Logger (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (8.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta6)
- MLKitCommon (~> 12.0)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
- SQLCipher (4.9.0):
- SQLCipher/standard (= 4.9.0)
- SQLCipher/common (4.9.0)
- SQLCipher/standard (4.9.0):
- SQLCipher (4.10.0):
- SQLCipher/standard (= 4.10.0)
- SQLCipher/common (4.10.0)
- SQLCipher/standard (4.10.0):
- SQLCipher/common
- TimesafariDailyNotificationPlugin (2.0.0):
- TimesafariDailyNotificationPlugin (4.0.1):
- Capacitor
- ZIPFoundation (0.9.19)
- ZIPFoundation (0.9.20)
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
@@ -110,8 +100,8 @@ SPEC REPOS:
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GoogleUtilitiesComponents
- GTMSessionFetcher
- IONFilesystemLib
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
@@ -148,33 +138,33 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@timesafari/daily-notification-plugin"
SPEC CHECKSUMS:
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
CapacitorClipboard: 4443c3cdb7c77b1533dfe3ff0f9f7756aa8579df
CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
Capacitor: 69dc07ebc6bd064747c5e76922f97e4862d9cc23
CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
CapacitorClipboard: d1f123674cf413125db816a45e8f70e8770972fc
CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
CapacitorShare: 25f7fc5dd0e4edbde5d6801c6de5d14a8b450a41
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
CapawesomeCapacitorFilePicker: 0f4a913a00e39dd77213449f0d917e92f35a5ca9
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
nanopb: 438bc412db1928dac798aa6fd75726007be04262
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
PODFILE CHECKSUM: 87c07d03f36ef38ab0c873802aee1ce9b5d34448
COCOAPODS: 1.16.2

8700
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{
"name": "timesafari",
"version": "1.3.8-beta",
"description": "Gift Economies Application",
"name": "giftopia",
"version": "1.4.4",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"
},
@@ -13,6 +14,7 @@
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"test:prerequisites": "node scripts/check-prerequisites.js",
"check:dependencies": "./scripts/check-dependencies.sh",
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
"test:all": "npm run lint && tsc && npm run test:web && npm run test:mobile && ./scripts/test-safety-check.sh && echo '\n\n\nGotta add the performance tests'",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
"test:mobile": "./scripts/test-mobile.sh",
@@ -28,7 +30,7 @@
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate",
"assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only",
@@ -138,19 +140,19 @@
},
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0",
"@capacitor/camera": "^6.0.0",
"@capacitor/cli": "^6.2.0",
"@capacitor/clipboard": "^6.0.2",
"@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capacitor/status-bar": "^6.0.2",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@capacitor-community/sqlite": "^7.0.3",
"@capacitor-mlkit/barcode-scanning": "^7.5.0",
"@capacitor/android": "^7.6.4",
"@capacitor/app": "^7.1.0",
"@capacitor/camera": "^7.0.5",
"@capacitor/cli": "^7.6.4",
"@capacitor/clipboard": "^7.0.4",
"@capacitor/core": "^7.6.4",
"@capacitor/filesystem": "^7.1.8",
"@capacitor/ios": "^7.6.4",
"@capacitor/share": "^7.0.4",
"@capacitor/status-bar": "^7.0.6",
"@capawesome/capacitor-file-picker": "^7.2.0",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 736 KiB

View File

@@ -192,6 +192,113 @@ check_ios_resources() {
log_success "iOS resource check completed"
}
# iOS app icon appearance variants (Dark, Tinted, …).
# Luminosity appearance values match Apple's asset catalog format (iOS 18+).
readonly _IOS_APP_ICON_APPEARANCE_LUMINOSITY="luminosity"
readonly _IOS_APP_ICON_APPEARANCE_DARK="dark"
readonly _IOS_APP_ICON_APPEARANCE_TINTED="tinted"
# Each row: source_file|luminosity_value|dest_filename
# To add a variant: append one row and place the source PNG under resources/ios/.
readonly _IOS_APP_ICON_APPEARANCE_VARIANTS=(
"resources/ios/icon/icon-dark.png|${_IOS_APP_ICON_APPEARANCE_DARK}|AppIcon-Dark.png"
"resources/ios/icon/icon-tinted.png|${_IOS_APP_ICON_APPEARANCE_TINTED}|AppIcon-Tinted.png"
)
# Update AppIcon.appiconset/Contents.json with one 1024×1024 appearance entry.
# Preserves all other images (including capacitor-assets output); replaces any
# existing entry for the same luminosity appearance.
_update_appicon_contents_for_appearance() {
local contents_json="$1"
local dest_filename="$2"
local luminosity_value="$3"
local tmp_file
tmp_file="$(mktemp)"
if ! jq --arg filename "$dest_filename" \
--arg appearance "$_IOS_APP_ICON_APPEARANCE_LUMINOSITY" \
--arg value "$luminosity_value" \
'
.images |= map(select((.appearances[0].value // "") != $value))
| .images += [{
"appearances": [{
"appearance": $appearance,
"value": $value
}],
"filename": $filename,
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}]
' "$contents_json" > "$tmp_file"; then
rm -f "$tmp_file"
log_error "Failed to update AppIcon Contents.json for appearance: $luminosity_value"
return 1
fi
mv "$tmp_file" "$contents_json"
}
# Install one appearance variant when its source PNG exists.
_apply_ios_app_icon_appearance_variant() {
local appiconset_dir="$1"
local contents_json="$2"
local source_file="$3"
local luminosity_value="$4"
local dest_filename="$5"
if [ ! -f "$source_file" ]; then
log_info "iOS app icon appearance variant not found ($source_file) — skipping"
return 0
fi
if [ ! -d "$appiconset_dir" ]; then
log_warn "AppIcon.appiconset not found — skipping appearance variant ($luminosity_value)"
return 0
fi
if ! cp "$source_file" "$appiconset_dir/$dest_filename"; then
log_error "Failed to copy $source_file to $appiconset_dir/$dest_filename"
return 1
fi
log_info "Installed iOS app icon appearance variant: $luminosity_value ($dest_filename)"
_update_appicon_contents_for_appearance "$contents_json" "$dest_filename" "$luminosity_value"
}
# Post-process capacitor-assets iOS icons: copy optional Dark/Tinted sources and
# register them in AppIcon.appiconset/Contents.json.
apply_ios_app_icon_appearances() {
local appiconset_dir="ios/App/App/Assets.xcassets/AppIcon.appiconset"
local contents_json="$appiconset_dir/Contents.json"
if [ ! -f "$contents_json" ]; then
log_info "AppIcon Contents.json not found — skipping appearance variants"
return 0
fi
if ! command -v jq &> /dev/null; then
log_error "jq is required to install iOS app icon appearance variants (install with: brew install jq)"
return 1
fi
local variant source_file luminosity_value dest_filename
for variant in "${_IOS_APP_ICON_APPEARANCE_VARIANTS[@]}"; do
IFS='|' read -r source_file luminosity_value dest_filename <<< "$variant"
_apply_ios_app_icon_appearance_variant \
"$appiconset_dir" "$contents_json" \
"$source_file" "$luminosity_value" "$dest_filename"
done
}
# Generate iOS assets (capacitor-assets), then apply optional appearance variants.
generate_ios_assets() {
ensure_ios_capacitor_asset_directories
npx capacitor-assets generate --ios
apply_ios_app_icon_appearances
}
# Function to clean iOS build
clean_ios_build() {
log_info "Cleaning iOS build artifacts..."
@@ -222,7 +329,9 @@ build_ios_app() {
if [ "$BUILD_TYPE" = "debug" ]; then
build_config="Debug"
destination="platform=iOS Simulator,name=iPhone 15 Pro"
# Use device SDK — prebuilt MLKit frameworks (MLImage, MLKitBarcodeScanning) ship
# iOS device slices only and cannot link against the iOS Simulator SDK.
destination="generic/platform=iOS"
else
build_config="Release"
destination="platform=iOS,id=auto"
@@ -232,18 +341,34 @@ build_ios_app() {
cd ios/App
# Build the app
xcodebuild -workspace App.xcworkspace \
# Prevent pkgx-managed libs (e.g. zlib) from leaking into the iOS SDK linker.
unset LIBRARY_PATH
unset DYLD_LIBRARY_PATH
unset DYLD_FALLBACK_LIBRARY_PATH
# Build the app:
# -quiet: skip the huge export VAR dump (compiler warnings still show unless suppressed below).
# SWIFT_SUPPRESS_WARNINGS / GCC_WARN_INHIBIT_ALL_WARNINGS: quiet CLI output from Pods + plugins;
# build in Xcode for full diagnostics. Real errors still fail the build.
local build_exit=0
xcodebuild -quiet \
-workspace App.xcworkspace \
-scheme "$scheme" \
-configuration "$build_config" \
-destination "$destination" \
build \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_ALLOWED=NO \
SWIFT_SUPPRESS_WARNINGS=YES \
GCC_WARN_INHIBIT_ALL_WARNINGS=YES || build_exit=$?
cd ../..
if [ $build_exit -ne 0 ]; then
return $build_exit
fi
log_success "iOS app built successfully"
}
@@ -406,7 +531,7 @@ fi
# Handle assets-only mode
if [ "$ASSETS_ONLY" = true ]; then
log_info "Assets-only mode: generating assets"
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
safe_execute "Generating assets" "generate_ios_assets" || exit 7
log_success "Assets generation completed successfully!"
exit 0
fi
@@ -555,7 +680,7 @@ safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaroun
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" "generate_ios_assets" || exit 7
# Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5
@@ -564,16 +689,19 @@ safe_execute "Building iOS app" "build_ios_app" || exit 5
if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..."
cd ios/App
xcodebuild -workspace App.xcworkspace \
xcodebuild -quiet \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-archivePath build/App.xcarchive \
archive \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_ALLOWED=NO \
SWIFT_SUPPRESS_WARNINGS=YES \
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
xcodebuild -exportArchive \
xcodebuild -quiet -exportArchive \
-archivePath build/App.xcarchive \
-exportPath build/ \
-exportOptionsPlist exportOptions.plist

View File

@@ -337,6 +337,27 @@ parse_args() {
fi
}
# iOS: capacitor-assets writes into AppIcon.appiconset and Splash.imageset under
# Assets.xcassets. Those paths are gitignored (generated). On a fresh clone the
# folders and Contents.json are missing; the tool opens Contents.json before writing
# PNGs, so we create minimal asset-catalog stubs when absent.
ensure_ios_capacitor_asset_directories() {
local base="ios/App/App/Assets.xcassets"
if [ ! -d "$base" ]; then
log_warn "Missing $base — cannot prepare iOS asset directories"
return 0
fi
mkdir -p "$base/AppIcon.appiconset" "$base/Splash.imageset"
local minimal_contents='{"images":[],"info":{"author":"xcode","version":1}}'
if [ ! -f "$base/AppIcon.appiconset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/AppIcon.appiconset/Contents.json"
fi
if [ ! -f "$base/Splash.imageset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/Splash.imageset/Contents.json"
fi
log_debug "Ensured iOS capacitor-assets output directories exist"
}
# Export functions for use in child scripts
export -f log_info log_success log_warn log_error log_debug log_step
export -f measure_time print_header print_footer
@@ -345,3 +366,4 @@ export -f safe_execute check_venv get_git_hash
export -f clean_build_artifacts validate_env_vars
export -f setup_build_env setup_app_directories load_env_file print_env_vars
export -f print_usage parse_args
export -f ensure_ios_capacitor_asset_directories

View File

@@ -8,14 +8,14 @@ notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-1">
<label class="block font-semibold text-lg capitalize text-center">
{{ stepLabel }}
</label>
<!-- Toggle link for entity type selection -->
<div class="text-right mb-4">
<div class="text-center mb-4">
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium"
class="text-xs text-blue-600 hover:underline uppercase"
@click="handleToggleEntityType"
>
{{ toggleLinkText }}

View File

@@ -129,7 +129,7 @@
@click="disabled ? notifyLocked() : addGroup()"
>
<font-awesome icon="plus" class="text-sm" />
New Group
New Do-Not-Pair Group
</button>
</div>
</template>

View File

@@ -39,7 +39,7 @@ import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
* ProjectSelectionDialog - Dialog for selecting a project
*
* Features:
* - EntityGrid integration for project selection
@@ -52,7 +52,7 @@ import { NotificationIface } from "../constants/app";
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
export default class ProjectSelectionDialog extends Vue {
/** Whether the dialog is visible */
visible = false;

View File

@@ -6,8 +6,8 @@
export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Gift Economies",
APP_NAME_NO_SPACES = "GiftEconomies",
APP_NAME = "Giftopia",
APP_NAME_NO_SPACES = APP_NAME,
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",

View File

@@ -1175,11 +1175,6 @@ export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM = {
message: "",
};
export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR = {
title: "Error",
message: "There was a problem deleting the image.",
};
export const NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER = {
title: "Missing Identifier",
message: "You must select an identifier before you can record a give.",

View File

@@ -80,6 +80,7 @@ export interface PlanActionClaim extends ClaimObject {
agent?: { identifier: string };
description?: string;
endTime?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
identifier?: string;
image?: string;
lastClaimId?: string;

View File

@@ -136,6 +136,11 @@ const routes: Array<RouteRecordRaw> = [
name: "help-onboarding",
component: () => import("../views/HelpOnboardingView.vue"),
},
{
path: "/help-terms",
name: "help-terms",
component: () => import("../views/HelpTermsView.vue"),
},
{
path: "/",
name: "home",

View File

@@ -123,10 +123,10 @@ export class CapacitorQRScanner implements QRScannerService {
// Add listener for barcode scans
const handle = await BarcodeScanner.addListener(
"barcodeScanned",
"barcodesScanned",
(result) => {
if (this.scanListener && result.barcode?.rawValue) {
this.scanListener.onScan(result.barcode.rawValue);
if (this.scanListener && result.barcodes?.[0]?.rawValue) {
this.scanListener.onScan(result.barcodes[0].rawValue);
}
},
);

View File

@@ -62,10 +62,6 @@ export class NativeNotificationService implements NotificationServiceInterface {
return true;
}
/**
* Request notification permissions from the OS
* Shows native permission dialog on first call
*/
/**
* Request notification permissions from the OS
* Shows native permission dialog on first call

View File

@@ -293,16 +293,12 @@
<h3
data-testid="advancedSettings"
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
@click="toggleShowGeneralAdvanced"
@click="showAdvanced = !showAdvanced"
>
{{
showGeneralAdvanced
? "Hide Advanced Settings"
: "Show Advanced Settings"
}}
{{ showAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings" }}
</h3>
<section
v-if="showGeneralAdvanced"
v-if="showAdvanced"
id="sectionAdvanced"
aria-labelledby="advancedHeading"
>
@@ -1095,6 +1091,7 @@ export default class AccountViewView extends Vue {
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.showAdvanced = this.showGeneralAdvanced;
this.showShortcutBvc = !!settings.showShortcutBvc;
this.warnIfProdServer = !!settings.warnIfProdServer;
this.warnIfTestServer = !!settings.warnIfTestServer;
@@ -1405,54 +1402,6 @@ export default class AccountViewView extends Vue {
}, 150);
}
/**
* Toggle dev-only 10-minute rollover for daily reminder. Saves the setting and,
* if reminder is already on, reschedules so the plugin uses the new interval.
*/
async toggleReminderFastRollover(): Promise<void> {
const next = !this.reminderFastRolloverForTesting;
await this.$saveSettings({
reminderFastRolloverForTesting: next,
});
this.reminderFastRolloverForTesting = next;
if (this.notifyingReminder) {
try {
const service = NotificationService.getInstance();
if (Capacitor.getPlatform() !== "android") {
await service.cancelDailyNotification();
}
const time24h = this.parseTimeTo24Hour(this.notifyingReminderTime);
const title = "Daily Reminder";
const body =
this.notifyingReminderMessage ||
"Click to share some gratitude with the world -- even if they're unnamed.";
await service.scheduleDailyNotification({
time: time24h,
title,
body,
priority: "normal",
...(next ? { rolloverIntervalMinutes: 10 } : {}),
});
this.notify.success(
next
? "Reminder will repeat every 10 minutes (testing)."
: "Reminder will repeat daily (24h).",
TIMEOUTS.STANDARD,
);
} catch (err) {
logger.error(
"[AccountViewView] Reschedule after fast-rollover toggle failed:",
err,
);
this.notify.error(
"Failed to update reminder interval. Please try again.",
TIMEOUTS.STANDARD,
);
}
}
}
/**
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
*/
@@ -2119,6 +2068,10 @@ export default class AccountViewView extends Vue {
hasLocation: result.includeLocation,
});
if (response.data.userMessage) {
this.notify.info(response.data.userMessage);
}
return result;
} else {
logger.debug("[AccountViewView] No profile data found in response:", {
@@ -2126,6 +2079,10 @@ export default class AccountViewView extends Vue {
hasData: !!response.data,
hasDataData: !!(response.data && response.data.data),
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
}
return null;
@@ -2232,6 +2189,10 @@ export default class AccountViewView extends Vue {
status: response.status,
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
return true;
} catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam
@@ -2348,6 +2309,10 @@ export default class AccountViewView extends Vue {
status: response.status,
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
return true;
} catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam

View File

@@ -398,6 +398,164 @@
</div>
</div>
<!--
Network Connections section: shows nearest neighbors in the registration
graph for all DIDs in this claim. The same conventions and styling are used
in UserProfileView.vue for user-profile nearest neighbors. Keep changes in sync.
-->
<div v-if="activeDid && hasVisibleNeighbors" class="mt-8">
<h2 class="text-lg font-semibold mb-3">
Network Connections
<button
title="What is this?"
class="ml-1 align-middle"
@click="showNeighborsInfo = true"
>
<font-awesome
icon="circle-info"
class="text-base text-blue-500 cursor-pointer"
/>
</button>
</h2>
<!-- Info modal for network connections explanation -->
<div
v-if="showNeighborsInfo"
class="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-16 z-50"
@click.self="showNeighborsInfo = false"
>
<div class="bg-white rounded-lg p-6 mx-4 max-w-md">
<h3 class="text-lg font-semibold mb-2">Network Connections</h3>
<p class="text-sm text-slate-700">
This section shows
{{
Object.values(claimNeighbors).flat().length === 1
? "a contact that is"
: "contacts that are"
}}
nearer to the people involved in this activity. If you want more
information, reach out to one of them and ask for an introduction.
</p>
<button
class="mt-4 w-full bg-blue-600 text-white py-2 rounded-md"
@click="showNeighborsInfo = false"
>
Got it
</button>
</div>
</div>
<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-if="Object.keys(claimNeighbors).length > 0">
<div v-for="(neighbors, did) in claimNeighbors" :key="did" class="mb-4">
<h3
v-if="Object.keys(claimNeighbors).length > 1"
class="text-sm font-medium text-slate-600 mb-1"
>
Near {{ didInfo(did as string) }}:
</h3>
<!-- DID has no linked neighbors on this server -->
<p
v-if="neighbors.length === 0"
class="text-sm text-slate-500 italic"
>
Nobody on this server is linked to {{ didInfo(did as string) }}. The
data may be a mistake, or a test, or a reference to someone on a
different system. Anyway, we have no way to contact them.
</p>
<div v-else 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 claim 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="{ path: '/did/' + encodeURIComponent(neighbor.did) }"
class="text-blue-600 hover:text-blue-800 font-medium underline"
>
Go to contact info
</router-link>
and send them the claim link from your clipboard. Ask them for
an introduction.
<div
v-if="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="
copyTextToClipboard(
'DID link',
`${APP_SERVER}/deep-link/did/${encodeURIComponent(neighbor.did)}`,
)
"
>
<font-awesome icon="copy" class="text-sm" />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!--
Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue
-->
@@ -631,12 +789,19 @@ export default class ClaimView extends Vue {
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
providersForGive: ProviderInfo[] = [];
showIdCopy = false;
showNeighborsInfo = false;
showVeriClaimDump = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowDeepLink = window.location.href; // changed in the setup for deep linking
// Network Connections state (same pattern as UserProfileView.vue)
claimNeighbors: Record<string, Array<{ did: string; relation: string }>> = {};
expandedNeighborDid: string | null = null;
loadingNeighbors = false;
neighborsError = "";
APP_SERVER = APP_SERVER;
R = R;
yaml = yaml;
@@ -745,6 +910,16 @@ export default class ClaimView extends Vue {
return (claim as { image?: string })?.image;
}
/**
* Whether the Network Connections section should be shown.
* Hidden if the only DIDs in claimNeighbors are the active user,
* or if there are no entries at all (after filtering).
*/
get hasVisibleNeighbors(): boolean {
const keys = Object.keys(this.claimNeighbors);
return keys.length > 0 || this.loadingNeighbors;
}
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
@@ -759,6 +934,10 @@ export default class ClaimView extends Vue {
this.isEditedGlobalId = false;
this.numConfsNotVisible = 0;
this.providersForGive = [];
this.claimNeighbors = {};
this.expandedNeighborDid = null;
this.loadingNeighbors = false;
this.neighborsError = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
this.veriClaimDidsVisible = {};
@@ -825,6 +1004,7 @@ export default class ClaimView extends Vue {
const claimId = this.$route.params.id as string;
if (claimId) {
await this.loadClaim(claimId, this.activeDid);
await this.loadClaimNeighbors();
} else {
this.notify.error("No claim ID was provided.");
}
@@ -1000,6 +1180,125 @@ export default class ClaimView extends Vue {
}
}
/**
* Loads nearest neighbors for all DIDs in this claim via the
* endorser-ch claimNearestNeighbors endpoint.
* Same display conventions as UserProfileView.vue's loadNeighbors.
*/
async loadClaimNeighbors() {
if (!this.veriClaim.id) return;
this.loadingNeighbors = true;
this.neighborsError = "";
try {
const url =
this.apiServer +
"/api/claim/claimNearestNeighbors/" +
encodeURIComponent(this.veriClaim.id as string);
const headers = await serverUtil.getHeaders(this.activeDid);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const raw = resp.data.data || {};
// Filter out the current user's own DID entry — their neighbors
// aren't useful here since "You" is already known.
const filtered: Record<
string,
Array<{ did: string; relation: string }>
> = {};
for (const [did, neighbors] of Object.entries(raw)) {
if (did === this.activeDid) continue;
filtered[did] = neighbors as Array<{
did: string;
relation: string;
}>;
}
this.claimNeighbors = filtered;
} else {
this.claimNeighbors = {};
this.neighborsError = "Failed to load network connections.";
}
} catch (error) {
await this.$logError(
"Error loading claim neighbors: " + JSON.stringify(error),
);
this.claimNeighbors = {};
this.neighborsError =
"An error occurred while loading network connections.";
} finally {
this.loadingNeighbors = false;
}
}
/**
* Gets display name for a neighbor DID (same as UserProfileView.vue)
*/
getNeighborDisplayName(did: string): string {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
neighborIsNotInContacts(did: string): boolean {
return !this.allContacts.some((contact) => contact.did === did);
}
/**
* Gets human-readable label for relation type (same as UserProfileView.vue)
*/
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 (same as UserProfileView.vue)
*/
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`;
}
}
/**
* Handles clicking expand on a neighbor - copies claim link and toggles
*/
async onNeighborExpandClick(did: string) {
if (this.expandedNeighborDid === did) {
this.expandedNeighborDid = null;
return;
}
try {
await copyToClipboard(this.windowDeepLink);
this.notify.copied("Claim link");
} catch (error) {
this.$logAndConsole(`Error copying claim link: ${error}`, true);
this.notify.error("Failed to copy claim link.");
}
this.expandedNeighborDid = did;
}
async showFullClaim(claimId: string) {
const url =
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
@@ -1110,6 +1409,7 @@ export default class ClaimView extends Vue {
(this.$router as Router).push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, this.activeDid);
await this.loadClaimNeighbors();
});
}

View File

@@ -118,11 +118,13 @@ import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
register,
setVisibilityUtil,
} from "../libs/endorserServer";
import UserNameDialog from "../components/UserNameDialog.vue";
import { retrieveAccountMetadata } from "../libs/util";
import { AxiosError } from "axios";
import { Account } from "@/db/tables/accounts";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import {
@@ -139,7 +141,10 @@ import {
NOTIFY_QR_URL_COPIED,
NOTIFY_QR_CODE_HELP,
NOTIFY_QR_DID_COPIED,
NOTIFY_QR_REGISTRATION_SUBMITTED,
NOTIFY_QR_REGISTRATION_ERROR,
createQRContactAddedMessage,
createQRRegistrationSuccessMessage,
QR_TIMEOUT_MEDIUM,
QR_TIMEOUT_STANDARD,
QR_TIMEOUT_LONG,
@@ -204,6 +209,7 @@ export default class ContactQRScanFull extends Vue {
activeDid = "";
apiServer = "";
givenName = "";
hideRegisterPromptOnNewContact = false;
isRegistered = false;
profileImageUrl = "";
qrValue = "";
@@ -278,6 +284,8 @@ export default class ContactQRScanFull extends Vue {
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
@@ -575,6 +583,34 @@ export default class ContactQRScanFull extends Vue {
createQRContactAddedMessage(!!this.activeDid),
QR_TIMEOUT_STANDARD,
);
if (
this.isRegistered &&
!this.hideRegisterPromptOnNewContact &&
!contact.registered
) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
},
onNo: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
},
onYes: async () => {
await this.register(contact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
} catch (error) {
logger.error("Error saving contact to database:", {
did: contact.did,
@@ -585,6 +621,74 @@ export default class ContactQRScanFull extends Vue {
}
}
async register(contact: Contact) {
logger.debug("Submitting contact registration", {
did: contact.did,
name: contact.name,
});
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
this.notify.toast("Submitted", NOTIFY_QR_REGISTRATION_SUBMITTED.message);
if (regResult.success) {
contact.registered = true;
await this.$updateContact(contact.did, { registered: true });
logger.debug("Contact registration successful", { did: contact.did });
this.notify.success(
createQRRegistrationSuccessMessage(contact.name || ""),
QR_TIMEOUT_LONG,
);
} else {
this.notify.error(
(regResult.error as string) || NOTIFY_QR_REGISTRATION_ERROR.message,
QR_TIMEOUT_LONG,
);
}
} catch (error) {
logger.error("Error registering contact:", {
did: contact.did,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (
serverError.response?.data &&
typeof serverError.response.data === "object" &&
"message" in serverError.response.data
) {
userMessage = (serverError.response.data as { message: string })
.message;
} else if (serverError.message) {
userMessage = serverError.message;
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
this.notify.error(userMessage, QR_TIMEOUT_LONG);
}
}
private async handleRegistrationPromptResponse(
stopAsking?: boolean,
): Promise<void> {
if (stopAsking) {
await this.$saveSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
}
/**
* Vue lifecycle hook - component mounted
* Sets up event listeners and starts scanning automatically

View File

@@ -897,7 +897,7 @@ export default class DiscoverView extends Vue {
public computedStarredTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"py-2": true,
"rounded-t-lg": true,
"border-b-2": true,

View File

@@ -1,4 +1,6 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<TopMessage />
@@ -263,7 +265,6 @@ import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR,
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER,
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE,
@@ -302,6 +303,7 @@ export default class GiftedDetails extends Vue {
giverName = "";
hideBackButton = false;
imageUrl = "";
imageUrlToDelete = "";
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<GiveActionClaim>;
@@ -517,7 +519,10 @@ export default class GiftedDetails extends Vue {
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
// Only delete freshly uploaded images, not ones from an existing claim
if (this.imageUrl && this.imageUrl !== this.prevCredToEdit?.claim?.image) {
this.deleteImage(this.imageUrl); // not awaiting, so they'll go back immediately
}
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
@@ -526,7 +531,10 @@ export default class GiftedDetails extends Vue {
}
cancelBack() {
this.deleteImage(); // not awaiting, so they'll go back immediately
// Only delete freshly uploaded images, not ones from an existing claim
if (this.imageUrl && this.imageUrl !== this.prevCredToEdit?.claim?.image) {
this.deleteImage(this.imageUrl); // not awaiting, so they'll go back immediately
}
(this.$router as Router).back();
}
@@ -539,13 +547,18 @@ export default class GiftedDetails extends Vue {
confirmDeleteImage() {
this.notify.confirm(
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM.message,
this.deleteImage,
() => {
// Stage the image for deletion on submit rather than deleting immediately,
// so that canceling the edit doesn't destroy the referenced image.
this.imageUrlToDelete = this.imageUrl;
this.imageUrl = "";
},
TIMEOUTS.LONG,
);
}
async deleteImage() {
if (!this.imageUrl) {
async deleteImage(imageUrl: string) {
if (!imageUrl) {
return;
}
try {
@@ -559,38 +572,21 @@ export default class GiftedDetails extends Vue {
);
}
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(imageUrl),
{ headers },
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
logger.error("Problem deleting image:", response);
this.notify.error(
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR.message,
TIMEOUTS.LONG,
);
return;
}
this.imageUrl = "";
} catch (error) {
logger.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any)?.response?.status === 404) {
logger.log("Weird: the image was already deleted.", error);
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
logger.log("Image was already deleted:", error);
} else {
this.notify.error(
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR.message,
TIMEOUTS.LONG,
);
logger.error("Failed to delete image from server:", error);
}
}
}
@@ -733,6 +729,12 @@ export default class GiftedDetails extends Vue {
TIMEOUTS.LONG,
);
} else {
// Delete the old image from storage now that the edit is saved
if (this.imageUrlToDelete) {
this.deleteImage(this.imageUrlToDelete); // not awaiting
this.imageUrlToDelete = "";
}
this.notify.success(
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message,
TIMEOUTS.SHORT,

View File

@@ -32,6 +32,9 @@
>
<h3>Troubleshooting Notifications</h3>
Note that the notifications will not arrive exactly at the time you set
(because phones don't let non-alarm-apps set exact alarms).
<h4>Check your in-app notification settings</h4>
<ul>
<li>Tap <strong>Profile</strong> in the bottom bar</li>

144
src/views/HelpTermsView.vue Normal file
View File

@@ -0,0 +1,144 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Terms & Conditions and Privacy Policies
</h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help 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"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<!-- eslint-disable prettier/prettier -->
<div>
<p style="display:inline; align-items: center">
This work is public domain. (If you like rules, reference
<a
href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1"
target="_blank"
rel="license noopener noreferrer"
>
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
src="../assets/help/creative-commons-circle.svg"
alt="CC circle"
width="20"
class="display: inline"
/>
<img
src="../assets/help/creative-commons-zero.svg"
alt="CC zero"
width="20"
style="display: inline"
/>
</a>
)
</p>
<p class="mt-4">
This is offered freely, with the hope that it helps but without any
warranty or guarantee. When you share data or even look at information here,
you accept the risk that goes with those activities. In other words,
if you expect some functionality or you expect some protection, and you
feel it is appropriate to force those expectations on the system or its
operators or creators, then you are not allowed to use it.
</p>
<p class="mt-4">
Here is how your data is used:
</p>
<ul class="list-disc list-outside ml-4">
<li>
If sending images, a server stores them. They can be removed by editing
each claim and deleting the image.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key
and message data are stored on a server. Those can be removed via
direct personal request (email
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500">
{{ SUPPORT_EMAIL }}
</a>).
</li>
<li>
For all other claim data,
<a
href="https://endorser.ch/privacy-policy"
target="_blank"
class="text-blue-500"
>
the Endorser Service has this Privacy Policy.
</a>
</li>
</ul>
<div class="mt-4">
<!--
This section is for Twilio's A2P Campaign requirements.
They say: Ensure it includes the program name, description, message/data rates, message frequency, support contact info, and opt-out instructions (HELP and STOP in bold).
They link here for a sample: https://help.twilio.com/articles/223134847-Industry-standards-for-US-Short-Code-Terms-of-Service
-->
Here are the details for SMS notifications:
<ul class="list-disc list-outside ml-4">
<li>You may opt to receive SMS messages for two purposes:
<ul class="list-disc list-outside ml-4">
<li>A daily reminder message</li>
<li>A notification of new activity for items that you are watching</li>
</ul>
</li>
<li>
Before enabling these notifications, you must register your phone number and give permission to use it for searches.
</li>
<li>
Once your phone number is registered and linked to your DID, you can enable or disable either kind of SMS message.
You can disable these any time with the same toggle.
</li>
<li>
If you lose your credentials, you can register your phone with a different DID.
Then you can enable and disable notifications for your phone.
</li>
<li>
Carriers are not liable for delayed or undelivered messages.
</li>
<li>
As always, message and data rates may apply for any messages sent to you from us and to us from you.
You will receive at most one of each kind of message per day.
If you have any questions about your text plan or data plan, it is best to contact your wireless provider.
</li>
<li>
Our servers will only store your phone number and the type of notifications you have enabled,
along with the explicit signed permission to use it for searches.
</li>
</ul>
</div>
</div>
<!-- eslint-enable -->
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "../components/QuickNav.vue";
import { SUPPORT_EMAIL } from "../constants/app";
@Component({ components: { QuickNav } })
export default class HelpTermsView extends Vue {
SUPPORT_EMAIL = SUPPORT_EMAIL;
}
</script>

View File

@@ -480,46 +480,14 @@
</p>
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<p style="display:inline; align-items: center">
This work is public domain. (If you like rules, reference
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
src="../assets/help/creative-commons-circle.svg"
alt="CC circle"
width="20"
class="display: inline"
/>
<img
src="../assets/help/creative-commons-zero.svg"
alt="CC zero"
width="20"
style="display: inline"
/>
</a>
.) This is offered freely, with the hope that it helps but without any warranty or guarantee;
if it helps you then enjoy using it,
but if you may try to forcibly collect damages for things you think it should do (or not do)
then don't use it.
<br />
As for data & privacy:
<p>
<router-link
class="text-blue-500"
:to="{ name: 'help-terms' }"
>
Read them here.
</router-link>
</p>
<ul class="list-disc list-outside ml-4">
<li>
If sending images, a server stores them. They can be removed by editing each claim
and deleting the image.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key and message
data are stored on a server. Those can be removed via direct personal request (via contact below).
</li>
<li>
For all other claim data,
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
</li>
</ul>
<h2 class="text-xl font-semibold">How can I contribute?</h2>
<p>

View File

@@ -109,12 +109,14 @@ Raymer * @version 1.0.0 */
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<div
class="bg-slate-100 border border-slate-300 rounded-md px-4 py-2.5 mb-4"
>
<div class="flex gap-2 justify-between items-center ps-8">
<!-- Thank button - always visible and unchanged -->
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 px-4 py-3 rounded-full"
class="text-center text-xl uppercase font-bold bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 mx-auto px-6 py-2 rounded-lg"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
@@ -122,25 +124,29 @@ Raymer * @version 1.0.0 */
</button>
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
<transition
enter-active-class="transition-all duration-1000 ease-out"
leave-active-class="transition-all duration-1000 ease-in"
enter-from-class="scale-0"
enter-to-class="scale-100"
leave-from-class="scale-100"
leave-to-class="scale-0"
enter-active-class="transition-all duration-500 ease-out"
leave-active-class="transition-all duration-500 ease-in"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isScrolled"
class="bg-gradient-to-t from-white to-transparent fixed inset-x-0 bottom-[calc(4.75rem+max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))] px-4 pb-3 w-full z-[49]"
>
<button
v-if="isScrolled"
type="button"
class="fixed bottom-10 p-4 w-14 h-14 z-50 text-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-full flex items-center justify-center"
:style="getButtonPosition()"
class="text-center text-xl uppercase font-bold bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 mx-auto px-6 py-2 rounded-lg drop-shadow-[0_0_10px_rgba(255,255,255,1)]"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
<span>Thank</span>
</button>
</div>
</transition>
<button
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
class="block text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openGiftedPrompts()"
>
<font-awesome
@@ -559,7 +565,7 @@ export default class HomeView extends Vue {
const scrollTop = appElement
? appElement.scrollTop
: window.pageYOffset || document.documentElement.scrollTop || 0;
const shouldBeScrolled = scrollTop > 100;
const shouldBeScrolled = scrollTop > 120;
if (this.isScrolled !== shouldBeScrolled) {
this.isScrolled = shouldBeScrolled;
}

View File

@@ -114,6 +114,49 @@
@assign="handleRepresentativeAssigned"
/>
<!-- Parent Project Selection -->
<div class="w-full flex items-stretch my-4">
<div
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
@click="openParentProjectDialog"
>
<div>
<font-awesome icon="folder" class="text-slate-400" />
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': parentProjectHandleId,
'text-slate-400': !parentProjectHandleId,
}"
class="truncate"
>
{{
parentProjectHandleId
? parentProjectName || "Parent Project"
: "Select Parent Project\u2026"
}}
</div>
</div>
</div>
<button
v-if="parentProjectHandleId"
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="unsetParentProject"
>
<font-awesome icon="trash-can" />
</button>
</div>
<ProjectSelectionDialog
ref="parentProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleParentProjectSelected"
/>
<div class="mb-4">
<p v-if="shouldShowOwnershipWarning">
<span class="text-red-500">Beware!</span>
@@ -283,6 +326,7 @@ import { LeafletMouseEvent } from "leaflet";
import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
import ProjectSelectionDialog from "../components/ProjectSelectionDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import {
AppString,
@@ -311,6 +355,7 @@ import {
PROJECT_TIMEOUT_VERY_LONG,
} from "../constants/notifications";
import { PlanActionClaim } from "../interfaces/claims";
import { PlanData } from "../interfaces/records";
import {
createEndorserJwtVcFromClaim,
getHeaders,
@@ -378,6 +423,7 @@ import { logger } from "../utils/logger";
components: {
EntityIcon,
ImageMethodDialog,
ProjectSelectionDialog,
ProjectRepresentativeDialog,
LMap,
LMarker,
@@ -429,6 +475,8 @@ export default class NewEditProjectView extends Vue {
latitude = 0;
longitude = 0;
numAccounts = 0;
parentProjectHandleId = "";
parentProjectName = "";
projectId = "";
projectIssuerDid = "";
sendToTrustroots = false;
@@ -510,6 +558,10 @@ export default class NewEditProjectView extends Vue {
);
}
}
if (this.fullClaim?.fulfills?.identifier) {
this.parentProjectHandleId = this.fullClaim.fulfills.identifier;
this.loadParentProjectName(this.parentProjectHandleId);
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.startTime as string,
@@ -623,6 +675,14 @@ export default class NewEditProjectView extends Vue {
} else {
delete vcClaim.agent;
}
if (this.parentProjectHandleId) {
vcClaim.fulfills = {
"@type": "PlanAction",
identifier: this.parentProjectHandleId,
};
} else {
delete vcClaim.fulfills;
}
if (this.imageUrl) {
vcClaim.image = this.imageUrl;
} else {
@@ -1075,5 +1135,33 @@ export default class NewEditProjectView extends Vue {
unsetRepresentative(): void {
this.agentDid = "";
}
openParentProjectDialog(): void {
(this.$refs.parentProjectDialog as ProjectSelectionDialog).open();
}
handleParentProjectSelected(project: PlanData): void {
this.parentProjectHandleId = project.handleId;
this.parentProjectName = project.name;
}
unsetParentProject(): void {
this.parentProjectHandleId = "";
this.parentProjectName = "";
}
private async loadParentProjectName(handleId: string): Promise<void> {
try {
const url =
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(handleId);
const headers = await getHeaders(this.activeDid);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data?.claim?.name) {
this.parentProjectName = resp.data.claim.name;
}
} catch {
// Parent project name will remain empty
}
}
}
</script>

View File

@@ -267,7 +267,7 @@
</form>
</div>
<MeetingProjectDialog
<ProjectSelectionDialog
ref="meetingProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
@@ -366,7 +366,8 @@
Do Not Pair Together
</h4>
<p class="text-xs text-gray-500 mb-2">
People in the same group will not be matched with each other.
People in the same do-not-pair group will not be matched with each
other.
</p>
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
Erase matches to change restrictions.
@@ -585,7 +586,7 @@ import TopMessage from "../components/TopMessage.vue";
import MeetingMembersList from "../components/MeetingMembersList.vue";
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectSelectionDialog from "../components/ProjectSelectionDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
errorStringForLog,
@@ -637,7 +638,7 @@ interface MeetingSetupInputs {
MeetingMembersList,
MeetingMemberMatch,
MeetingExclusionGroups,
MeetingProjectDialog,
ProjectSelectionDialog,
ProjectIcon,
},
mixins: [PlatformServiceMixin],
@@ -1468,7 +1469,7 @@ export default class OnboardMeetingView extends Vue {
* Open the project link selection dialog
*/
openProjectLinkDialog(): void {
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
(this.$refs.meetingProjectDialog as ProjectSelectionDialog).open();
}
/**

View File

@@ -193,7 +193,7 @@
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mt-3">
Projects That Contribute To This
These Projects Are Part Of This
</h3>
<!--
centering because long, wrapped project names didn't left align with blank
@@ -218,7 +218,7 @@
<div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Projects Getting Contributions From This
This Project Is Part Of These
</h3>
<!--
centering because long, wrapped project names didn't left align with blank

View File

@@ -223,7 +223,7 @@ export default class QuickActionBvcBeginView extends Vue {
);
this.notify.error(
timeResult?.error || NOTIFY_BVC_TIME_ERROR.message,
TIMEOUTS.LONG,
timeResult?.error ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
);
}
}
@@ -251,7 +251,7 @@ export default class QuickActionBvcBeginView extends Vue {
);
this.notify.error(
attendResult?.error || NOTIFY_BVC_ATTENDANCE_ERROR.message,
TIMEOUTS.LONG,
attendResult?.error ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
);
}
}
@@ -276,7 +276,7 @@ export default class QuickActionBvcBeginView extends Vue {
logger.error("[QuickActionBvcBeginView] Error sending claims:", error);
this.notify.error(
error.userMessage || NOTIFY_BVC_SUBMISSION_ERROR.message,
TIMEOUTS.LONG,
error.userMessage ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
);
}
}

View File

@@ -54,7 +54,11 @@
</p>
</div>
<!-- Nearest Neighbors Section -->
<!--
Network Connections section: shows nearest neighbors in the registration
graph for this user profile. The same conventions and styling are used in
ClaimView.vue for claim-level nearest neighbors. Keep changes in sync.
-->
<div
v-if="
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
@@ -63,7 +67,46 @@
"
class="mt-6"
>
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
<h2 class="text-lg font-semibold mb-3">
Network Connections
<button
title="What is this?"
class="ml-1 align-middle"
@click="showNeighborsInfo = true"
>
<font-awesome
icon="circle-info"
class="text-base text-blue-500 cursor-pointer"
/>
</button>
</h2>
<!-- Info modal for network connections explanation -->
<div
v-if="showNeighborsInfo"
class="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-16 z-50"
@click.self="showNeighborsInfo = false"
>
<div class="bg-white rounded-lg p-6 mx-4 max-w-md">
<h3 class="text-lg font-semibold mb-2">Network Connections</h3>
<p class="text-sm text-slate-700">
This section shows
{{
neighbors.length === 1
? "a contact that is"
: "contacts that are"
}}
nearer to this person. If you want more information, reach out to
one of them and ask for an introduction.
</p>
<button
class="mt-4 w-full bg-blue-600 text-white py-2 rounded-md"
@click="showNeighborsInfo = false"
>
Got it
</button>
</div>
</div>
<div v-if="loadingNeighbors">
<div class="flex justify-center items-center py-8">
@@ -124,8 +167,8 @@
>
Go to contact info
</router-link>
and send them the link in your clipboard and ask for an
introduction to this person.
and send them the profile link from your clipboard. Ask them to
introduce you to this person.
<div
v-if="
getNeighborDisplayName(neighbor.did) === '' ||
@@ -269,6 +312,7 @@ export default class UserProfileView extends Vue {
neighborsError = "";
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null;
showNeighborsInfo = false;
// make this function available to the Vue template
didInfo = didInfo;

View File

@@ -130,11 +130,13 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Refresh home view and check gift
await page.goto('./');
// Wait for async data (e.g. "new offers" banner) to finish loading so the layout
// is stable before clicking — otherwise a layout shift can redirect the click.
await page.waitForLoadState('networkidle');
// Firefox complains on load the initial feed here when we use the test server.
// It may be similar to the CORS problem below.
const item = await page.locator('li:first-child').filter({ hasText: finalTitle });
await item.locator('[data-testid="circle-info-link"]').click();
const item = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
await expect(item).toBeVisible();
await item.click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();