Compare commits
26 Commits
entitygrid
...
integrate-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2deb84aa42 | ||
|
|
5528c44f2b | ||
|
|
28a825a460 | ||
|
|
b585c4d183 | ||
|
|
80d5199259 | ||
|
|
816c7a6582 | ||
|
|
9ea9f4969e | ||
| 5050156beb | |||
| d265a9f78c | |||
| f848de15f1 | |||
| ebaf2dedf0 | |||
|
|
749204f96b | ||
|
|
831532739c | ||
|
|
5f17f6cb4e | ||
|
|
5def44c349 | ||
|
|
45eff4a9ac | ||
|
|
ae5f1a33a7 | ||
|
|
95ac1afcd2 | ||
|
|
49c62b2b69 | ||
|
|
7ae3b241dd | ||
|
|
ced8248436 | ||
|
|
70059e5a31 | ||
|
|
602fe394fa | ||
|
|
1f858fa1ce | ||
|
|
f9446f529b | ||
|
|
d576920810 |
57
BUILDING.md
57
BUILDING.md
@@ -1151,28 +1151,28 @@ If you need to build manually or want to understand the individual steps:
|
|||||||
|
|
||||||
- ... and you may have to fix these, especially with pkgx:
|
- ... and you may have to fix these, especially with pkgx:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gem_path=$(which gem)
|
gem_path=$(which gem)
|
||||||
shortened_path="${gem_path:h:h}"
|
shortened_path="${gem_path:h:h}"
|
||||||
export GEM_HOME=$shortened_path
|
export GEM_HOME=$shortened_path
|
||||||
export GEM_PATH=$shortened_path
|
export GEM_PATH=$shortened_path
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
|
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ios/App && xcrun agvtool new-version 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/g" App.xcodeproj/project.pbxproj && cd -
|
cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd -
|
||||||
# Unfortunately this edits Info.plist directly.
|
# Unfortunately this edits Info.plist directly.
|
||||||
#xcrun agvtool new-marketing-version 0.4.5
|
#xcrun agvtool new-marketing-version 0.4.5
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
|
|
||||||
Here's prod. Also available: test, dev
|
Here's prod. Also available: test, dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:ios:prod
|
npm run build:ios:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
3.1. Use Xcode to build and run on simulator or device.
|
3.1. Use Xcode to build and run on simulator or device.
|
||||||
|
|
||||||
@@ -1197,7 +1197,8 @@ If you need to build manually or want to understand the individual steps:
|
|||||||
- It can take 15 minutes for the build to show up in the list of builds.
|
- It can take 15 minutes for the build to show up in the list of builds.
|
||||||
- You'll probably have to "Manage" something about encryption, disallowed in France.
|
- You'll probably have to "Manage" something about encryption, disallowed in France.
|
||||||
- Then "Save" and "Add to Review" and "Resubmit to App Review".
|
- Then "Save" and "Add to Review" and "Resubmit to App Review".
|
||||||
- Eventually it'll be "Ready for Distribution" which means
|
- Eventually it'll be "Ready for Distribution" which means it's live
|
||||||
|
- When finished, bump package.json version
|
||||||
|
|
||||||
### Android Build
|
### Android Build
|
||||||
|
|
||||||
@@ -1315,26 +1316,26 @@ The recommended way to build for Android is using the automated build script:
|
|||||||
|
|
||||||
#### Android Manual Build Process
|
#### Android Manual Build Process
|
||||||
|
|
||||||
##### 1. Bump the version in package.json, then here: android/app/build.gradle
|
##### 1. Bump the version in package.json, then update these versions & run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
perl -p -i -e 's/versionCode .*/versionCode 46/g' android/app/build.gradle
|
perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
|
||||||
perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
|
|
||||||
Here's prod. Also available: test, dev
|
Here's prod. Also available: test, dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:android:prod
|
npm run build:android:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 3. Open the project in Android Studio
|
##### 3. Open the project in Android Studio
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx cap open android
|
npx cap open android
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 4. Use Android Studio to build and run on emulator or device
|
##### 4. Use Android Studio to build and run on emulator or device
|
||||||
|
|
||||||
@@ -1379,6 +1380,8 @@ At play.google.com/console:
|
|||||||
- Note that if you add testers, you have to go to "Publishing Overview" and send
|
- Note that if you add testers, you have to go to "Publishing Overview" and send
|
||||||
those changes or your (closed) testers won't see it.
|
those changes or your (closed) testers won't see it.
|
||||||
|
|
||||||
|
- When finished, bump package.json version
|
||||||
|
|
||||||
### Capacitor Operations
|
### Capacitor Operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.2] - 2025.11.06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bad page when user follows prompt to backup seed
|
||||||
|
|
||||||
|
|
||||||
## [1.1.1] - 2025.11.03
|
## [1.1.1] - 2025.11.03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ android {
|
|||||||
applicationId "app.timesafari.app"
|
applicationId "app.timesafari.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 46
|
versionCode 47
|
||||||
versionName "1.1.1"
|
versionName "1.1.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -101,6 +101,8 @@ dependencies {
|
|||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
implementation project(':capacitor-community-sqlite')
|
implementation project(':capacitor-community-sqlite')
|
||||||
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
||||||
|
// Gson for JSON parsing in native notification fetcher
|
||||||
|
implementation "com.google.code.gson:gson:2.10.1"
|
||||||
testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dependencies {
|
|||||||
implementation project(':capacitor-share')
|
implementation project(':capacitor-share')
|
||||||
implementation project(':capacitor-status-bar')
|
implementation project(':capacitor-status-bar')
|
||||||
implementation project(':capawesome-capacitor-file-picker')
|
implementation project(':capawesome-capacitor-file-picker')
|
||||||
|
implementation project(':timesafari-daily-notification-plugin')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8" ?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
|
android:name=".TimeSafariApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -36,6 +37,30 @@
|
|||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- Daily Notification Plugin Receivers -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.timesafari.daily.NOTIFICATION" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.BootReceiver"
|
||||||
|
android:directBootAware="true"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter android:priority="1000">
|
||||||
|
<!-- Delivered very early after reboot (before unlock) -->
|
||||||
|
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||||
|
<!-- Delivered after the user unlocks / credential-encrypted storage is available -->
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<!-- Delivered after app update; great for rescheduling alarms without reboot -->
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
@@ -45,4 +70,15 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||||
|
|
||||||
|
<!-- Notification permissions -->
|
||||||
|
<!-- POST_NOTIFICATIONS required for Android 13+ (API 33+) -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- SCHEDULE_EXACT_ALARM required for Android 12+ (API 31+) to schedule exact alarms -->
|
||||||
|
<!-- Note: On Android 12+, users can grant/deny this permission -->
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
|
||||||
|
<!-- RECEIVE_BOOT_COMPLETED needed to reschedule notifications after device reboot -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -34,5 +34,9 @@
|
|||||||
{
|
{
|
||||||
"pkg": "@capawesome/capacitor-file-picker",
|
"pkg": "@capawesome/capacitor-file-picker",
|
||||||
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@timesafari/daily-notification-plugin",
|
||||||
|
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* TimeSafariApplication.java
|
||||||
|
*
|
||||||
|
* Application class for the TimeSafari app.
|
||||||
|
* Registers the native content fetcher for the Daily Notification Plugin.
|
||||||
|
*
|
||||||
|
* @author TimeSafari Team
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package app.timesafari;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
import com.timesafari.dailynotification.DailyNotificationPlugin;
|
||||||
|
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application class that registers native fetcher for daily notifications
|
||||||
|
*/
|
||||||
|
public class TimeSafariApplication extends Application {
|
||||||
|
|
||||||
|
private static final String TAG = "TimeSafariApplication";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
|
// Instrumentation: Log app initialization with process info
|
||||||
|
int pid = android.os.Process.myPid();
|
||||||
|
String processName = getApplicationInfo().processName;
|
||||||
|
Log.i(TAG, String.format(
|
||||||
|
"APP|ON_CREATE ts=%d pid=%d processName=%s",
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
pid,
|
||||||
|
processName
|
||||||
|
));
|
||||||
|
|
||||||
|
Log.i(TAG, "Initializing TimeSafari Application");
|
||||||
|
|
||||||
|
// Create notification channel for daily notifications (required for Android 8.0+)
|
||||||
|
createNotificationChannel();
|
||||||
|
|
||||||
|
// Register native fetcher with application context
|
||||||
|
Context context = getApplicationContext();
|
||||||
|
NativeNotificationContentFetcher nativeFetcher =
|
||||||
|
new TimeSafariNativeFetcher(context);
|
||||||
|
|
||||||
|
// Instrumentation: Log before registration
|
||||||
|
Log.i(TAG, String.format(
|
||||||
|
"FETCHER|REGISTER_START instanceHash=%d ts=%d",
|
||||||
|
nativeFetcher.hashCode(),
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
|
||||||
|
DailyNotificationPlugin.setNativeFetcher(nativeFetcher);
|
||||||
|
|
||||||
|
// Instrumentation: Verify registration succeeded
|
||||||
|
NativeNotificationContentFetcher verified =
|
||||||
|
DailyNotificationPlugin.getNativeFetcherStatic();
|
||||||
|
boolean registered = (verified != null && verified == nativeFetcher);
|
||||||
|
|
||||||
|
Log.i(TAG, String.format(
|
||||||
|
"FETCHER|REGISTERED providerKey=DailyNotificationPlugin instanceHash=%d registered=%s ts=%d",
|
||||||
|
nativeFetcher.hashCode(),
|
||||||
|
registered,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
|
||||||
|
Log.i(TAG, "Native fetcher registered: " + nativeFetcher.getClass().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create notification channel for daily notifications
|
||||||
|
* Required for Android 8.0 (API 26) and above
|
||||||
|
*/
|
||||||
|
private void createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
NotificationManager notificationManager =
|
||||||
|
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
|
||||||
|
if (notificationManager == null) {
|
||||||
|
Log.w(TAG, "NotificationManager is null, cannot create channel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel ID must match the one used in DailyNotificationWorker
|
||||||
|
String channelId = "timesafari.daily";
|
||||||
|
String channelName = "Daily Notifications";
|
||||||
|
String channelDescription = "Daily notification updates from TimeSafari";
|
||||||
|
|
||||||
|
// Check if channel already exists
|
||||||
|
NotificationChannel existingChannel = notificationManager.getNotificationChannel(channelId);
|
||||||
|
if (existingChannel != null) {
|
||||||
|
Log.d(TAG, "Notification channel already exists: " + channelId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the channel with high importance (for priority="high" notifications)
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
);
|
||||||
|
channel.setDescription(channelDescription);
|
||||||
|
channel.enableVibration(true);
|
||||||
|
channel.setShowBadge(true);
|
||||||
|
|
||||||
|
notificationManager.createNotificationChannel(channel);
|
||||||
|
Log.i(TAG, "Notification channel created: " + channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
/**
|
||||||
|
* TimeSafariNativeFetcher.java
|
||||||
|
*
|
||||||
|
* Implementation of NativeNotificationContentFetcher for the TimeSafari app.
|
||||||
|
* Fetches notification content from the endorser API endpoint.
|
||||||
|
*
|
||||||
|
* @author TimeSafari Team
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package app.timesafari;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.util.Log;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import com.timesafari.dailynotification.FetchContext;
|
||||||
|
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
|
import com.timesafari.dailynotification.NotificationContent;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native content fetcher implementation for TimeSafari
|
||||||
|
*
|
||||||
|
* Fetches notification content from the endorser API endpoint.
|
||||||
|
* Uses the same endpoint as the TypeScript code: /api/v2/report/plansLastUpdatedBetween
|
||||||
|
*/
|
||||||
|
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
|
||||||
|
|
||||||
|
private static final String TAG = "TimeSafariNativeFetcher";
|
||||||
|
private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween";
|
||||||
|
private static final int CONNECT_TIMEOUT_MS = 10000; // 10 seconds
|
||||||
|
private static final int READ_TIMEOUT_MS = 15000; // 15 seconds
|
||||||
|
private static final int MAX_RETRIES = 3; // Maximum number of retry attempts
|
||||||
|
private static final int RETRY_DELAY_MS = 1000; // Base delay for exponential backoff
|
||||||
|
|
||||||
|
// SharedPreferences constants
|
||||||
|
// NOTE: Must match plugin's SharedPreferences name and keys for starred plans
|
||||||
|
// Plugin uses "daily_notification_timesafari" (see DailyNotificationPlugin.updateStarredPlans)
|
||||||
|
private static final String PREFS_NAME = "daily_notification_timesafari";
|
||||||
|
private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds"; // Matches plugin key
|
||||||
|
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id"; // For pagination
|
||||||
|
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
private final Context appContext;
|
||||||
|
private SharedPreferences prefs;
|
||||||
|
|
||||||
|
// Volatile fields for configuration, set via configure() method
|
||||||
|
private volatile String apiBaseUrl;
|
||||||
|
private volatile String activeDid;
|
||||||
|
private volatile String jwtToken; // Pre-generated JWT token from TypeScript (ES256K signed)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param context Application context for SharedPreferences access
|
||||||
|
*/
|
||||||
|
public TimeSafariNativeFetcher(Context context) {
|
||||||
|
this.appContext = context.getApplicationContext();
|
||||||
|
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: Initialized with context");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the native fetcher with API credentials
|
||||||
|
*
|
||||||
|
* Called by the plugin when configureNativeFetcher() is invoked from TypeScript.
|
||||||
|
* This method stores the configuration for use in background fetches.
|
||||||
|
*
|
||||||
|
* <p><b>Architecture Note:</b> The JWT token is pre-generated in TypeScript using
|
||||||
|
* TimeSafari's {@code accessTokenForBackground()} function (ES256K DID-based signing).
|
||||||
|
* The native fetcher just uses the token directly - no JWT generation needed.</p>
|
||||||
|
*
|
||||||
|
* @param apiBaseUrl Base URL for API server (e.g., "https://api.endorser.ch")
|
||||||
|
* @param activeDid Active DID for authentication (e.g., "did:ethr:0x...")
|
||||||
|
* @param jwtToken Pre-generated JWT token (ES256K signed) from TypeScript
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
|
||||||
|
// Instrumentation: Log configuration start
|
||||||
|
int pid = android.os.Process.myPid();
|
||||||
|
Log.i(TAG, String.format(
|
||||||
|
"FETCHER|CONFIGURE_START instanceHash=%d pid=%d ts=%d",
|
||||||
|
this.hashCode(),
|
||||||
|
pid,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
|
||||||
|
this.apiBaseUrl = apiBaseUrl;
|
||||||
|
this.activeDid = activeDid;
|
||||||
|
this.jwtToken = jwtToken;
|
||||||
|
|
||||||
|
// Instrumentation: Log configuration completion
|
||||||
|
boolean configured = (apiBaseUrl != null && activeDid != null && jwtToken != null);
|
||||||
|
Log.i(TAG, String.format(
|
||||||
|
"FETCHER|CONFIGURE_COMPLETE instanceHash=%d configured=%s apiBaseUrl=%s activeDid=%s jwtLength=%d ts=%d",
|
||||||
|
this.hashCode(),
|
||||||
|
configured,
|
||||||
|
apiBaseUrl != null ? apiBaseUrl : "null",
|
||||||
|
activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null",
|
||||||
|
jwtToken != null ? jwtToken.length() : 0,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Enhanced logging for JWT diagnostic purposes
|
||||||
|
Log.i(TAG, "TimeSafariNativeFetcher: Configured with API: " + apiBaseUrl);
|
||||||
|
if (activeDid != null) {
|
||||||
|
Log.i(TAG, "TimeSafariNativeFetcher: ActiveDID: " + activeDid.substring(0, Math.min(30, activeDid.length())) +
|
||||||
|
(activeDid.length() > 30 ? "..." : ""));
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "TimeSafariNativeFetcher: ActiveDID is NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwtToken != null) {
|
||||||
|
Log.i(TAG, "TimeSafariNativeFetcher: JWT token received - Length: " + jwtToken.length() + " chars");
|
||||||
|
// Log first and last 10 chars for verification (not full token for security)
|
||||||
|
String tokenPreview = jwtToken.length() > 20
|
||||||
|
? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10)
|
||||||
|
: jwtToken.substring(0, Math.min(jwtToken.length(), 20)) + "...";
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: JWT preview: " + tokenPreview);
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: JWT token is NULL - API calls will fail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public CompletableFuture<List<NotificationContent>> fetchContent(
|
||||||
|
@NonNull FetchContext context) {
|
||||||
|
|
||||||
|
// Instrumentation: Log fetch start with context
|
||||||
|
int pid = android.os.Process.myPid();
|
||||||
|
Log.i(TAG, String.format(
|
||||||
|
"PREFETCH|START id=%s notifyAt=%d trigger=%s instanceHash=%d pid=%d ts=%d",
|
||||||
|
context.scheduledTime != null ? "daily_" + context.scheduledTime : "unknown",
|
||||||
|
context.scheduledTime != null ? context.scheduledTime : 0,
|
||||||
|
context.trigger,
|
||||||
|
this.hashCode(),
|
||||||
|
pid,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: Fetch triggered - trigger=" + context.trigger +
|
||||||
|
", scheduledTime=" + context.scheduledTime + ", fetchTime=" + context.fetchTime);
|
||||||
|
|
||||||
|
// Start with retry count 0
|
||||||
|
return fetchContentWithRetry(context, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch content with retry logic for transient errors
|
||||||
|
*
|
||||||
|
* @param context Fetch context
|
||||||
|
* @param retryCount Current retry attempt (0 for first attempt)
|
||||||
|
* @return Future with notification contents or empty list on failure
|
||||||
|
*/
|
||||||
|
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
|
||||||
|
@NonNull FetchContext context, int retryCount) {
|
||||||
|
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
// Check if configured
|
||||||
|
if (apiBaseUrl == null || activeDid == null || jwtToken == null) {
|
||||||
|
Log.e(TAG, String.format(
|
||||||
|
"PREFETCH|SOURCE from=fallback reason=not_configured apiBaseUrl=%s activeDid=%s jwtToken=%s ts=%d",
|
||||||
|
apiBaseUrl != null ? "set" : "null",
|
||||||
|
activeDid != null ? "set" : "null",
|
||||||
|
jwtToken != null ? "set" : "null",
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Not configured. Call configureNativeFetcher() from TypeScript first.");
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instrumentation: Log native fetcher usage
|
||||||
|
Log.i(TAG, String.format(
|
||||||
|
"PREFETCH|SOURCE from=native instanceHash=%d apiBaseUrl=%s ts=%d",
|
||||||
|
this.hashCode(),
|
||||||
|
apiBaseUrl,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
|
||||||
|
Log.i(TAG, "TimeSafariNativeFetcher: Starting fetch from " + apiBaseUrl + ENDORSER_ENDPOINT);
|
||||||
|
|
||||||
|
// Build request URL
|
||||||
|
String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
|
||||||
|
URL url = new URL(urlString);
|
||||||
|
|
||||||
|
// Create HTTP connection
|
||||||
|
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||||
|
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||||
|
connection.setReadTimeout(READ_TIMEOUT_MS);
|
||||||
|
connection.setRequestMethod("POST");
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// Diagnostic logging for JWT usage
|
||||||
|
if (jwtToken != null) {
|
||||||
|
String jwtPreview = jwtToken.length() > 20
|
||||||
|
? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10)
|
||||||
|
: jwtToken;
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: Using JWT for API call - Length: " + jwtToken.length() +
|
||||||
|
", Preview: " + jwtPreview + ", ActiveDID: " +
|
||||||
|
(activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null"));
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: JWT token is NULL when making API call!");
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer " + jwtToken);
|
||||||
|
connection.setDoOutput(true);
|
||||||
|
|
||||||
|
// Build request body
|
||||||
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
|
requestBody.put("planIds", getStarredPlanIds());
|
||||||
|
|
||||||
|
// afterId is required by the API endpoint
|
||||||
|
// Use "0" for first request (no previous data), or stored jwtId for subsequent requests
|
||||||
|
String afterId = getLastAcknowledgedJwtId();
|
||||||
|
if (afterId == null || afterId.isEmpty()) {
|
||||||
|
afterId = "0"; // First request - start from beginning
|
||||||
|
}
|
||||||
|
requestBody.put("afterId", afterId);
|
||||||
|
|
||||||
|
String jsonBody = gson.toJson(requestBody);
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: Request body: " + jsonBody);
|
||||||
|
|
||||||
|
// Write request body
|
||||||
|
try (OutputStream os = connection.getOutputStream()) {
|
||||||
|
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
||||||
|
os.write(input, 0, input.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
int responseCode = connection.getResponseCode();
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: HTTP response code: " + responseCode);
|
||||||
|
|
||||||
|
if (responseCode == 200) {
|
||||||
|
// Read response
|
||||||
|
BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
StringBuilder response = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
response.append(line);
|
||||||
|
}
|
||||||
|
reader.close();
|
||||||
|
|
||||||
|
String responseBody = response.toString();
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: Response body length: " + responseBody.length());
|
||||||
|
|
||||||
|
// Parse response and convert to NotificationContent
|
||||||
|
List<NotificationContent> contents = parseApiResponse(responseBody, context);
|
||||||
|
|
||||||
|
// Update last acknowledged JWT ID from the response (for pagination)
|
||||||
|
if (!contents.isEmpty()) {
|
||||||
|
// Get the last JWT ID from the parsed response (stored during parsing)
|
||||||
|
updateLastAckedJwtIdFromResponse(contents, responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "TimeSafariNativeFetcher: Successfully fetched " + contents.size() +
|
||||||
|
" notification(s)");
|
||||||
|
|
||||||
|
// Instrumentation: Log successful fetch
|
||||||
|
Log.i(TAG, String.format(
|
||||||
|
"PREFETCH|WRITE_OK id=%s items=%d ts=%d",
|
||||||
|
context.scheduledTime != null ? "daily_" + context.scheduledTime : "unknown",
|
||||||
|
contents.size(),
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Read error response
|
||||||
|
String errorMessage = "Unknown error";
|
||||||
|
try {
|
||||||
|
BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8));
|
||||||
|
StringBuilder errorResponse = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
errorResponse.append(line);
|
||||||
|
}
|
||||||
|
reader.close();
|
||||||
|
errorMessage = errorResponse.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "TimeSafariNativeFetcher: Could not read error stream", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: API error " + responseCode + ": " + errorMessage);
|
||||||
|
|
||||||
|
// Handle retryable errors (5xx server errors, network timeouts)
|
||||||
|
if (shouldRetry(responseCode, retryCount)) {
|
||||||
|
long delayMs = RETRY_DELAY_MS * (1 << retryCount); // Exponential backoff
|
||||||
|
Log.w(TAG, "TimeSafariNativeFetcher: Retryable error, retrying in " + delayMs + "ms " +
|
||||||
|
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(delayMs);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Retry delay interrupted", e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive retry
|
||||||
|
return fetchContentWithRetry(context, retryCount + 1).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-retryable errors (4xx client errors, max retries reached)
|
||||||
|
if (responseCode >= 400 && responseCode < 500) {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Non-retryable client error " + responseCode);
|
||||||
|
} else if (retryCount >= MAX_RETRIES) {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Max retries (" + MAX_RETRIES + ") reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty list on error (fallback will be handled by worker)
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (java.net.SocketTimeoutException | java.net.UnknownHostException e) {
|
||||||
|
// Network errors are retryable
|
||||||
|
Log.w(TAG, "TimeSafariNativeFetcher: Network error during fetch", e);
|
||||||
|
|
||||||
|
if (shouldRetry(0, retryCount)) { // Use 0 as response code for network errors
|
||||||
|
long delayMs = RETRY_DELAY_MS * (1 << retryCount);
|
||||||
|
Log.w(TAG, "TimeSafariNativeFetcher: Retrying after network error in " + delayMs + "ms " +
|
||||||
|
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
|
||||||
|
|
||||||
|
try {
|
||||||
|
Thread.sleep(delayMs);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Retry delay interrupted", ie);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchContentWithRetry(context, retryCount + 1).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Max retries reached for network error");
|
||||||
|
return Collections.emptyList();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Error during fetch", e);
|
||||||
|
// Non-retryable errors (parsing, configuration, etc.)
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an error should be retried
|
||||||
|
*
|
||||||
|
* @param responseCode HTTP response code (0 for network errors)
|
||||||
|
* @param retryCount Current retry attempt count
|
||||||
|
* @return true if error is retryable and retry count not exceeded
|
||||||
|
*/
|
||||||
|
private boolean shouldRetry(int responseCode, int retryCount) {
|
||||||
|
if (retryCount >= MAX_RETRIES) {
|
||||||
|
return false; // Max retries exceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry on network errors (responseCode 0) or server errors (5xx)
|
||||||
|
// Don't retry on client errors (4xx) as they indicate permanent issues
|
||||||
|
if (responseCode == 0) {
|
||||||
|
return true; // Network error (timeout, unknown host, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseCode >= 500 && responseCode < 600) {
|
||||||
|
return true; // Server error (retryable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some 4xx errors might be retryable (e.g., 429 Too Many Requests)
|
||||||
|
if (responseCode == 429) {
|
||||||
|
return true; // Rate limit - retry with backoff
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Other client errors (401, 403, 404, etc.) are not retryable
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get starred plan IDs from SharedPreferences
|
||||||
|
*
|
||||||
|
* @return List of starred plan IDs, empty list if none stored
|
||||||
|
*/
|
||||||
|
private List<String> getStarredPlanIds() {
|
||||||
|
try {
|
||||||
|
// Use the same SharedPreferences as the plugin (not the instance variable 'prefs')
|
||||||
|
// Plugin stores in "daily_notification_timesafari" with key "starredPlanIds"
|
||||||
|
SharedPreferences pluginPrefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||||
|
String idsJson = pluginPrefs.getString(KEY_STARRED_PLAN_IDS, "[]");
|
||||||
|
|
||||||
|
if (idsJson == null || idsJson.isEmpty() || idsJson.equals("[]")) {
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: No starred plan IDs found in SharedPreferences");
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON array (plugin stores as JSON string)
|
||||||
|
JsonParser parser = new JsonParser();
|
||||||
|
JsonArray jsonArray = parser.parse(idsJson).getAsJsonArray();
|
||||||
|
List<String> planIds = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < jsonArray.size(); i++) {
|
||||||
|
planIds.add(jsonArray.get(i).getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "TimeSafariNativeFetcher: Loaded " + planIds.size() + " starred plan IDs from SharedPreferences");
|
||||||
|
if (planIds.size() > 0) {
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: First plan ID: " +
|
||||||
|
planIds.get(0).substring(0, Math.min(30, planIds.get(0).length())) + "...");
|
||||||
|
}
|
||||||
|
return planIds;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Error loading starred plan IDs from SharedPreferences", e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last acknowledged JWT ID from SharedPreferences (for pagination)
|
||||||
|
*
|
||||||
|
* @return Last acknowledged JWT ID, or null if none stored
|
||||||
|
*/
|
||||||
|
private String getLastAcknowledgedJwtId() {
|
||||||
|
try {
|
||||||
|
String jwtId = prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
|
||||||
|
if (jwtId != null) {
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: Loaded last acknowledged JWT ID");
|
||||||
|
}
|
||||||
|
return jwtId;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Error loading last acknowledged JWT ID", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last acknowledged JWT ID from the API response
|
||||||
|
* Uses the last JWT ID from the data array for pagination
|
||||||
|
*
|
||||||
|
* @param contents Parsed notification contents (may contain JWT IDs)
|
||||||
|
* @param responseBody Original response body for parsing
|
||||||
|
*/
|
||||||
|
private void updateLastAckedJwtIdFromResponse(List<NotificationContent> contents, String responseBody) {
|
||||||
|
try {
|
||||||
|
JsonParser parser = new JsonParser();
|
||||||
|
JsonObject root = parser.parse(responseBody).getAsJsonObject();
|
||||||
|
JsonArray dataArray = root.getAsJsonArray("data");
|
||||||
|
|
||||||
|
if (dataArray != null && dataArray.size() > 0) {
|
||||||
|
// Get the last item's JWT ID (most recent)
|
||||||
|
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
|
||||||
|
|
||||||
|
// Try to get JWT ID from different possible locations in response structure
|
||||||
|
String jwtId = null;
|
||||||
|
if (lastItem.has("jwtId")) {
|
||||||
|
jwtId = lastItem.get("jwtId").getAsString();
|
||||||
|
} else if (lastItem.has("plan")) {
|
||||||
|
JsonObject plan = lastItem.getAsJsonObject("plan");
|
||||||
|
if (plan.has("jwtId")) {
|
||||||
|
jwtId = plan.get("jwtId").getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwtId != null && !jwtId.isEmpty()) {
|
||||||
|
updateLastAckedJwtId(jwtId);
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: Updated last acknowledged JWT ID: " +
|
||||||
|
jwtId.substring(0, Math.min(20, jwtId.length())) + "...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "TimeSafariNativeFetcher: Could not extract JWT ID from response for pagination", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last acknowledged JWT ID in SharedPreferences
|
||||||
|
*
|
||||||
|
* @param jwtId JWT ID to store as last acknowledged
|
||||||
|
*/
|
||||||
|
private void updateLastAckedJwtId(String jwtId) {
|
||||||
|
try {
|
||||||
|
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
|
||||||
|
Log.d(TAG, "TimeSafariNativeFetcher: Updated last acknowledged JWT ID");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Error updating last acknowledged JWT ID", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse API response and convert to NotificationContent list
|
||||||
|
*/
|
||||||
|
private List<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
|
||||||
|
List<NotificationContent> contents = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonParser parser = new JsonParser();
|
||||||
|
JsonObject root = parser.parse(responseBody).getAsJsonObject();
|
||||||
|
|
||||||
|
// Parse response structure (matches PlansLastUpdatedResponse)
|
||||||
|
JsonArray dataArray = root.getAsJsonArray("data");
|
||||||
|
if (dataArray != null) {
|
||||||
|
for (int i = 0; i < dataArray.size(); i++) {
|
||||||
|
JsonObject item = dataArray.get(i).getAsJsonObject();
|
||||||
|
|
||||||
|
NotificationContent content = new NotificationContent();
|
||||||
|
|
||||||
|
// Extract data from API response
|
||||||
|
// Support both flat structure (jwtId, planId) and nested (plan.jwtId, plan.handleId)
|
||||||
|
String planId = null;
|
||||||
|
String jwtId = null;
|
||||||
|
|
||||||
|
if (item.has("planId")) {
|
||||||
|
planId = item.get("planId").getAsString();
|
||||||
|
} else if (item.has("plan")) {
|
||||||
|
JsonObject plan = item.getAsJsonObject("plan");
|
||||||
|
if (plan.has("handleId")) {
|
||||||
|
planId = plan.get("handleId").getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.has("jwtId")) {
|
||||||
|
jwtId = item.get("jwtId").getAsString();
|
||||||
|
} else if (item.has("plan")) {
|
||||||
|
JsonObject plan = item.getAsJsonObject("plan");
|
||||||
|
if (plan.has("jwtId")) {
|
||||||
|
jwtId = plan.get("jwtId").getAsString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification ID
|
||||||
|
String notificationId = "endorser_" + (jwtId != null ? jwtId :
|
||||||
|
System.currentTimeMillis() + "_" + i);
|
||||||
|
content.setId(notificationId);
|
||||||
|
|
||||||
|
// Create notification title
|
||||||
|
String title = "Project Update";
|
||||||
|
if (planId != null) {
|
||||||
|
title = "Update: " + planId.substring(Math.max(0, planId.length() - 8));
|
||||||
|
}
|
||||||
|
content.setTitle(title);
|
||||||
|
|
||||||
|
// Create notification body
|
||||||
|
StringBuilder body = new StringBuilder();
|
||||||
|
if (planId != null) {
|
||||||
|
body.append("Plan ").append(planId.substring(Math.max(0, planId.length() - 12))).append(" has been updated.");
|
||||||
|
} else {
|
||||||
|
body.append("A project you follow has been updated.");
|
||||||
|
}
|
||||||
|
content.setBody(body.toString());
|
||||||
|
|
||||||
|
// Use scheduled time from context, or default to 1 hour from now
|
||||||
|
long scheduledTimeMs = context.scheduledTime != null ?
|
||||||
|
context.scheduledTime : (System.currentTimeMillis() + 3600000);
|
||||||
|
content.setScheduledTime(scheduledTimeMs);
|
||||||
|
|
||||||
|
// Set notification properties
|
||||||
|
content.setPriority("default");
|
||||||
|
content.setSound(true);
|
||||||
|
|
||||||
|
contents.add(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no data items, create a default notification
|
||||||
|
if (contents.isEmpty()) {
|
||||||
|
NotificationContent defaultContent = new NotificationContent();
|
||||||
|
defaultContent.setId("endorser_no_updates_" + System.currentTimeMillis());
|
||||||
|
defaultContent.setTitle("No Project Updates");
|
||||||
|
defaultContent.setBody("No updates found in your starred projects.");
|
||||||
|
|
||||||
|
long scheduledTimeMs = context.scheduledTime != null ?
|
||||||
|
context.scheduledTime : (System.currentTimeMillis() + 3600000);
|
||||||
|
defaultContent.setScheduledTime(scheduledTimeMs);
|
||||||
|
defaultContent.setPriority("default");
|
||||||
|
defaultContent.setSound(true);
|
||||||
|
|
||||||
|
contents.add(defaultContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "TimeSafariNativeFetcher: Error parsing API response", e);
|
||||||
|
// Return empty list on parse error
|
||||||
|
}
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -28,3 +28,6 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
|
|||||||
|
|
||||||
include ':capawesome-capacitor-file-picker'
|
include ':capawesome-capacitor-file-picker'
|
||||||
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
|
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
|
||||||
|
|
||||||
|
include ':timesafari-daily-notification-plugin'
|
||||||
|
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')
|
||||||
|
|||||||
1146
doc/daily-notification-plugin-integration-plan.md
Normal file
1146
doc/daily-notification-plugin-integration-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
109
docs/directives/fix-notification-dismiss-cancel.mdc
Normal file
109
docs/directives/fix-notification-dismiss-cancel.mdc
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Fix Notification Dismiss to Cancel Notification
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a user clicks the "Dismiss" button on a daily notification, the notification is removed from storage and alarms are cancelled, but the notification itself is not cancelled from the NotificationManager. This means the notification remains visible in the system tray even though it's been dismissed.
|
||||||
|
|
||||||
|
Additionally, clicking on the notification (not the dismiss button) launches the app, which is working as intended.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
In `DailyNotificationWorker.java`, the `handleDismissNotification()` method:
|
||||||
|
1. ✅ Removes notification from storage
|
||||||
|
2. ✅ Cancels pending alarms
|
||||||
|
3. ❌ **MISSING**: Does not cancel the notification from NotificationManager
|
||||||
|
|
||||||
|
The notification is displayed with ID = `content.getId().hashCode()` (line 440), but this ID is never used to cancel the notification when dismissing.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Add notification cancellation to `handleDismissNotification()` method in `DailyNotificationWorker.java`.
|
||||||
|
|
||||||
|
### IMPORTANT: Plugin Source Change
|
||||||
|
|
||||||
|
**This change must be applied to the plugin source repository**, not the host app. The file is located in the `@timesafari/daily-notification-plugin` package.
|
||||||
|
|
||||||
|
### File to Modify
|
||||||
|
|
||||||
|
**Plugin Source Repository:**
|
||||||
|
`android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
|
||||||
|
|
||||||
|
**Note:** In the host app's `node_modules`, this file is located at:
|
||||||
|
`node_modules/@timesafari/daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
|
||||||
|
|
||||||
|
However, changes to `node_modules` will be overwritten on the next `npm install`. This fix must be applied to the plugin's source repository.
|
||||||
|
|
||||||
|
### Change Required
|
||||||
|
|
||||||
|
In the `handleDismissNotification()` method (around line 177-206), add code to cancel the notification from NotificationManager:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private Result handleDismissNotification(String notificationId) {
|
||||||
|
Trace.beginSection("DN:dismiss");
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
|
||||||
|
|
||||||
|
// Cancel the notification from NotificationManager FIRST
|
||||||
|
// This ensures the notification disappears immediately when dismissed
|
||||||
|
NotificationManager notificationManager =
|
||||||
|
(NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (notificationManager != null) {
|
||||||
|
int systemNotificationId = notificationId.hashCode();
|
||||||
|
notificationManager.cancel(systemNotificationId);
|
||||||
|
Log.d(TAG, "DN|DISMISS_CANCEL_NOTIF systemId=" + systemNotificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from Room if present; also remove from legacy storage for compatibility
|
||||||
|
try {
|
||||||
|
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||||
|
// No direct delete DAO exposed via service; legacy removal still applied
|
||||||
|
} catch (Exception ignored) { }
|
||||||
|
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
|
||||||
|
storage.removeNotification(notificationId);
|
||||||
|
|
||||||
|
// Cancel any pending alarms
|
||||||
|
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
|
||||||
|
getApplicationContext(),
|
||||||
|
(android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
|
||||||
|
);
|
||||||
|
scheduler.cancelNotification(notificationId);
|
||||||
|
|
||||||
|
Log.i(TAG, "DN|DISMISS_OK id=" + notificationId);
|
||||||
|
return Result.success();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "DN|DISMISS_ERR exception id=" + notificationId + " err=" + e.getMessage(), e);
|
||||||
|
return Result.retry();
|
||||||
|
} finally {
|
||||||
|
Trace.endSection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Points
|
||||||
|
|
||||||
|
1. **Notification ID**: Use `notificationId.hashCode()` to match the ID used when displaying (line 440: `int notificationId = content.getId().hashCode()`)
|
||||||
|
2. **Order**: Cancel the notification FIRST, before removing from storage, so it disappears immediately
|
||||||
|
3. **Null check**: Check that NotificationManager is not null before calling cancel()
|
||||||
|
4. **Logging**: Add instrumentation log to track cancellation
|
||||||
|
|
||||||
|
### Expected Behavior After Fix
|
||||||
|
|
||||||
|
1. User clicks "Dismiss" button → Notification disappears immediately from system tray
|
||||||
|
2. User clicks notification body → App launches (unchanged behavior)
|
||||||
|
3. User swipes notification away → Notification dismissed (Android handles this automatically with `setAutoCancel(true)`)
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Click dismiss button → Notification disappears immediately
|
||||||
|
- [ ] Click notification body → App launches
|
||||||
|
- [ ] Swipe notification away → Notification dismissed
|
||||||
|
- [ ] Check logs for `DN|DISMISS_CANCEL_NOTIF` entry
|
||||||
|
- [ ] Verify notification is removed from storage after dismiss
|
||||||
|
- [ ] Verify alarms are cancelled after dismiss
|
||||||
|
|
||||||
|
## Related Code
|
||||||
|
|
||||||
|
- Notification display: `DailyNotificationWorker.displayNotification()` line 440
|
||||||
|
- Notification ID generation: `content.getId().hashCode()`
|
||||||
|
- Auto-cancel: `builder.setAutoCancel(true)` line 363 (handles swipe-to-dismiss)
|
||||||
109
docs/prefetch-investigation-summary.md
Normal file
109
docs/prefetch-investigation-summary.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Prefetch Investigation Summary
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The daily notification prefetch job (T-5 min) is not calling the native fetcher, resulting in:
|
||||||
|
- `from: null` in prefetch logs
|
||||||
|
- Fallback/mock content being used
|
||||||
|
- `DISPLAY_SKIP content_not_found` at notification time
|
||||||
|
- Storage empty (`[]`) when display worker runs
|
||||||
|
|
||||||
|
## Root Cause Hypothesis
|
||||||
|
|
||||||
|
Based on the directive analysis, likely causes (ranked):
|
||||||
|
|
||||||
|
1. **Registration Timing**: Prefetch worker runs before `Application.onCreate()` completes
|
||||||
|
2. **Discovery Failure**: Worker resolves fetcher to `null` (wrong scope, process mismatch)
|
||||||
|
3. **Persistence Bug**: Content written but wiped/deduped before display
|
||||||
|
4. **ID Mismatch**: Prefetch writes `notify_...` but display looks for `daily_...`
|
||||||
|
|
||||||
|
## Instrumentation Added
|
||||||
|
|
||||||
|
### TimeSafariApplication.java
|
||||||
|
- `APP|ON_CREATE ts=... pid=... processName=...` - App initialization timing
|
||||||
|
- `FETCHER|REGISTER_START instanceHash=... ts=...` - Before registration
|
||||||
|
- `FETCHER|REGISTERED providerKey=... instanceHash=... registered=... ts=...` - After registration with verification
|
||||||
|
|
||||||
|
### TimeSafariNativeFetcher.java
|
||||||
|
- `FETCHER|CONFIGURE_START instanceHash=... pid=... ts=...` - Configuration start
|
||||||
|
- `FETCHER|CONFIGURE_COMPLETE instanceHash=... configured=... apiBaseUrl=... activeDid=... jwtLength=... ts=...` - Configuration completion
|
||||||
|
- `PREFETCH|START id=... notifyAt=... trigger=... instanceHash=... pid=... ts=...` - Fetch start
|
||||||
|
- `PREFETCH|SOURCE from=native/fallback reason=... ts=...` - Source resolution
|
||||||
|
- `PREFETCH|WRITE_OK id=... items=... ts=...` - Successful fetch
|
||||||
|
|
||||||
|
## Diagnostic Tools
|
||||||
|
|
||||||
|
### Log Filtering Script
|
||||||
|
```bash
|
||||||
|
./scripts/diagnose-prefetch.sh app.timesafari.app
|
||||||
|
```
|
||||||
|
|
||||||
|
Filters logcat for:
|
||||||
|
- `APP|ON_CREATE`
|
||||||
|
- `FETCHER|*`
|
||||||
|
- `PREFETCH|*`
|
||||||
|
- `DISPLAY|*`
|
||||||
|
- `STORAGE|*`
|
||||||
|
|
||||||
|
### Manual Filtering
|
||||||
|
```bash
|
||||||
|
adb logcat | grep -E "APP\|ON_CREATE|FETCHER\||PREFETCH\||DISPLAY\||STORAGE\|"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Investigation Checklist
|
||||||
|
|
||||||
|
### A. App/Plugin Initialization Order
|
||||||
|
- [ ] Confirm `APP|ON_CREATE` appears before `PREFETCH|START`
|
||||||
|
- [ ] Verify `FETCHER|REGISTERED registered=true`
|
||||||
|
- [ ] Check for multiple `onCreate` invocations (process restarts)
|
||||||
|
- [ ] Confirm single process (no `android:process` on workers)
|
||||||
|
|
||||||
|
### B. Prefetch Worker Resolution
|
||||||
|
- [ ] Check `PREFETCH|SOURCE from=native` (not `from=fallback`)
|
||||||
|
- [ ] Verify `instanceHash` matches between registration and fetch
|
||||||
|
- [ ] Compare `pid` values (should be same process)
|
||||||
|
- [ ] Check `FETCHER|CONFIGURE_COMPLETE configured=true` before prefetch
|
||||||
|
|
||||||
|
### C. Storage & Persistence
|
||||||
|
- [ ] Verify `PREFETCH|WRITE_OK items>=1`
|
||||||
|
- [ ] Check storage logs for content persistence
|
||||||
|
- [ ] Compare prefetch ID vs display lookup ID (must match)
|
||||||
|
|
||||||
|
### D. ID Schema Consistency
|
||||||
|
- [ ] Prefetch ID format: `daily_<epoch>` or `notify_<epoch>`
|
||||||
|
- [ ] Display lookup ID format: must match prefetch ID
|
||||||
|
- [ ] Verify ID derivation rules are consistent
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Run diagnostic script** during a notification cycle
|
||||||
|
2. **Analyze logs** for timing issues and process mismatches
|
||||||
|
3. **If fetcher is null**: Implement Fix #2 (Pass Fetcher Context With Work) or Fix #3 (Process-Safe DI)
|
||||||
|
4. **If ID mismatch**: Normalize ID schema across prefetch and display
|
||||||
|
5. **If storage issue**: Add transactional writes and read-after-write verification
|
||||||
|
|
||||||
|
## Expected Log Flow (Success Case)
|
||||||
|
|
||||||
|
```
|
||||||
|
APP|ON_CREATE ts=... pid=... processName=app.timesafari.app
|
||||||
|
FETCHER|REGISTER_START instanceHash=... ts=...
|
||||||
|
FETCHER|REGISTERED providerKey=DailyNotificationPlugin instanceHash=... registered=true ts=...
|
||||||
|
FETCHER|CONFIGURE_START instanceHash=... pid=... ts=...
|
||||||
|
FETCHER|CONFIGURE_COMPLETE instanceHash=... configured=true ... ts=...
|
||||||
|
PREFETCH|START id=daily_... notifyAt=... trigger=prefetch instanceHash=... pid=... ts=...
|
||||||
|
PREFETCH|SOURCE from=native instanceHash=... apiBaseUrl=... ts=...
|
||||||
|
PREFETCH|WRITE_OK id=daily_... items=1 ts=...
|
||||||
|
STORAGE|POST_PREFETCH total=1 ids=[daily_...]
|
||||||
|
DISPLAY|START id=daily_...
|
||||||
|
STORAGE|PRE_DISPLAY total=1 ids=[daily_...]
|
||||||
|
DISPLAY|LOOKUP result=hit id=daily_...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Failure Indicators
|
||||||
|
|
||||||
|
- `PREFETCH|SOURCE from=fallback` - Native fetcher not resolved
|
||||||
|
- `PREFETCH|SOURCE from=null` - Fetcher registration failed
|
||||||
|
- `FETCHER|REGISTERED registered=false` - Registration verification failed
|
||||||
|
- `STORAGE|PRE_DISPLAY total=0` - Content not persisted
|
||||||
|
- `DISPLAY|LOOKUP result=miss` - ID mismatch or content cleared
|
||||||
|
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 46;
|
CURRENT_PROJECT_VERSION = 47;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.1;
|
MARKETING_VERSION = 1.1.2;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 46;
|
CURRENT_PROJECT_VERSION = 47;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.1;
|
MARKETING_VERSION = 1.1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.2-beta",
|
"version": "1.1.3-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.2-beta",
|
"version": "1.1.3-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/electron": "^5.0.1",
|
"@capacitor-community/electron": "^5.0.1",
|
||||||
"@capacitor-community/sqlite": "6.0.2",
|
"@capacitor-community/sqlite": "6.0.2",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||||
"@simplewebauthn/browser": "^10.0.0",
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"@simplewebauthn/server": "^10.0.0",
|
"@simplewebauthn/server": "^10.0.0",
|
||||||
|
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
|
||||||
"@tweenjs/tween.js": "^21.1.1",
|
"@tweenjs/tween.js": "^21.1.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@veramo/core": "^5.6.0",
|
"@veramo/core": "^5.6.0",
|
||||||
@@ -149,6 +150,43 @@
|
|||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../daily-notification-plugin": {
|
||||||
|
"name": "@timesafari/daily-notification-plugin",
|
||||||
|
"version": "1.0.11",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/core": "^6.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/android": "^6.2.1",
|
||||||
|
"@capacitor/cli": "^6.2.1",
|
||||||
|
"@capacitor/ios": "^6.2.1",
|
||||||
|
"@types/jest": "^29.5.0",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/node": "^20.19.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||||
|
"@typescript-eslint/parser": "^5.57.0",
|
||||||
|
"eslint": "^8.37.0",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"jest-environment-jsdom": "^30.0.5",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"markdownlint-cli2": "^0.18.1",
|
||||||
|
"prettier": "^2.8.7",
|
||||||
|
"rimraf": "^4.4.0",
|
||||||
|
"rollup": "^3.20.0",
|
||||||
|
"rollup-plugin-typescript2": "^0.31.0",
|
||||||
|
"standard-version": "^9.5.0",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"typescript": "~5.2.0",
|
||||||
|
"vite": "^7.1.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@0no-co/graphql.web": {
|
"node_modules/@0no-co/graphql.web": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
||||||
@@ -9605,6 +9643,10 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@timesafari/daily-notification-plugin": {
|
||||||
|
"resolved": "../daily-notification-plugin",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@tootallnate/once": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.2-beta",
|
"version": "1.1.3-beta",
|
||||||
"description": "Time Safari Application",
|
"description": "Time Safari Application",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Time Safari Team"
|
"name": "Time Safari Team"
|
||||||
@@ -166,6 +166,7 @@
|
|||||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||||
"@simplewebauthn/browser": "^10.0.0",
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"@simplewebauthn/server": "^10.0.0",
|
"@simplewebauthn/server": "^10.0.0",
|
||||||
|
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
|
||||||
"@tweenjs/tween.js": "^21.1.1",
|
"@tweenjs/tween.js": "^21.1.1",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@veramo/core": "^5.6.0",
|
"@veramo/core": "^5.6.0",
|
||||||
|
|||||||
@@ -436,7 +436,21 @@ fi
|
|||||||
log_info "Cleaning dist directory..."
|
log_info "Cleaning dist directory..."
|
||||||
clean_build_artifacts "dist"
|
clean_build_artifacts "dist"
|
||||||
|
|
||||||
# Step 4: Build Capacitor version with mode
|
# Step 4: Run TypeScript type checking for test and production builds
|
||||||
|
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
|
||||||
|
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
|
||||||
|
|
||||||
|
if ! measure_time npm run type-check; then
|
||||||
|
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "TypeScript type checking completed for $BUILD_MODE mode"
|
||||||
|
else
|
||||||
|
log_debug "Skipping TypeScript type checking for development mode"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Build Capacitor version with mode
|
||||||
if [ "$BUILD_MODE" = "development" ]; then
|
if [ "$BUILD_MODE" = "development" ]; then
|
||||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||||
elif [ "$BUILD_MODE" = "test" ]; then
|
elif [ "$BUILD_MODE" = "test" ]; then
|
||||||
@@ -445,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
|
|||||||
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 5: Clean Gradle build
|
# Step 6: Clean Gradle build
|
||||||
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
|
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
|
||||||
|
|
||||||
# Step 6: Build based on type
|
# Step 7: Build based on type
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||||
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 7: Sync with Capacitor
|
# Step 8: Sync with Capacitor
|
||||||
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
|
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
|
||||||
|
|
||||||
# Step 8: Generate assets
|
# Step 9: Generate assets
|
||||||
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
|
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
|
||||||
|
|
||||||
# Step 9: Build APK/AAB if requested
|
# Step 10: Build APK/AAB if requested
|
||||||
if [ "$BUILD_APK" = true ]; then
|
if [ "$BUILD_APK" = true ]; then
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||||
@@ -474,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
|
|||||||
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
|
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 10: Auto-run app if requested
|
# Step 11: Auto-run app if requested
|
||||||
if [ "$AUTO_RUN" = true ]; then
|
if [ "$AUTO_RUN" = true ]; then
|
||||||
log_step "Auto-running Android app..."
|
log_step "Auto-running Android app..."
|
||||||
safe_execute "Launching app" "npx cap run android" || {
|
safe_execute "Launching app" "npx cap run android" || {
|
||||||
@@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
|
|||||||
log_success "Android app launched successfully!"
|
log_success "Android app launched successfully!"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 11: Open Android Studio if requested
|
# Step 12: Open Android Studio if requested
|
||||||
if [ "$OPEN_STUDIO" = true ]; then
|
if [ "$OPEN_STUDIO" = true ]; then
|
||||||
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
|
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -381,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
|
|||||||
log_info "Cleaning dist directory..."
|
log_info "Cleaning dist directory..."
|
||||||
clean_build_artifacts "dist"
|
clean_build_artifacts "dist"
|
||||||
|
|
||||||
# Step 4: Build Capacitor version with mode
|
# Step 4: Run TypeScript type checking for test and production builds
|
||||||
|
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
|
||||||
|
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
|
||||||
|
|
||||||
|
if ! measure_time npm run type-check; then
|
||||||
|
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "TypeScript type checking completed for $BUILD_MODE mode"
|
||||||
|
else
|
||||||
|
log_debug "Skipping TypeScript type checking for development mode"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Build Capacitor version with mode
|
||||||
if [ "$BUILD_MODE" = "development" ]; then
|
if [ "$BUILD_MODE" = "development" ]; then
|
||||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||||
elif [ "$BUILD_MODE" = "test" ]; then
|
elif [ "$BUILD_MODE" = "test" ]; then
|
||||||
@@ -390,16 +404,16 @@ elif [ "$BUILD_MODE" = "production" ]; then
|
|||||||
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 5: Sync with Capacitor
|
# Step 6: Sync with Capacitor
|
||||||
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
||||||
|
|
||||||
# Step 6: Generate assets
|
# Step 7: Generate assets
|
||||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||||
|
|
||||||
# Step 7: Build iOS app
|
# Step 8: Build iOS app
|
||||||
safe_execute "Building iOS app" "build_ios_app" || exit 5
|
safe_execute "Building iOS app" "build_ios_app" || exit 5
|
||||||
|
|
||||||
# Step 8: Build IPA/App if requested
|
# Step 9: Build IPA/App if requested
|
||||||
if [ "$BUILD_IPA" = true ]; then
|
if [ "$BUILD_IPA" = true ]; then
|
||||||
log_info "Building IPA package..."
|
log_info "Building IPA package..."
|
||||||
cd ios/App
|
cd ios/App
|
||||||
@@ -426,12 +440,12 @@ if [ "$BUILD_APP" = true ]; then
|
|||||||
log_success "App bundle built successfully"
|
log_success "App bundle built successfully"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 9: Auto-run app if requested
|
# Step 10: Auto-run app if requested
|
||||||
if [ "$AUTO_RUN" = true ]; then
|
if [ "$AUTO_RUN" = true ]; then
|
||||||
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
|
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 10: Open Xcode if requested
|
# Step 11: Open Xcode if requested
|
||||||
if [ "$OPEN_STUDIO" = true ]; then
|
if [ "$OPEN_STUDIO" = true ]; then
|
||||||
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
|
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
|
||||||
fi
|
fi
|
||||||
|
|||||||
36
scripts/diagnose-prefetch.sh
Executable file
36
scripts/diagnose-prefetch.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Diagnostic script for daily notification prefetch issues
|
||||||
|
# Filters logcat output for prefetch-related instrumentation logs
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/diagnose-prefetch.sh [package_name]
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# ./scripts/diagnose-prefetch.sh app.timesafari.app
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PACKAGE_NAME="${1:-app.timesafari.app}"
|
||||||
|
|
||||||
|
echo "🔍 Daily Notification Prefetch Diagnostic Tool"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
echo "Package: $PACKAGE_NAME"
|
||||||
|
echo "Filtering for instrumentation tags:"
|
||||||
|
echo " - APP|ON_CREATE"
|
||||||
|
echo " - FETCHER|*"
|
||||||
|
echo " - PREFETCH|*"
|
||||||
|
echo " - DISPLAY|*"
|
||||||
|
echo " - STORAGE|*"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Filter logcat for instrumentation tags
|
||||||
|
adb logcat -c # Clear logcat buffer first
|
||||||
|
|
||||||
|
adb logcat | grep -E "APP\|ON_CREATE|FETCHER\||PREFETCH\||DISPLAY\||STORAGE\||DailyNotification|TimeSafariApplication|TimeSafariNativeFetcher" | \
|
||||||
|
grep -i "$PACKAGE_NAME\|TimeSafari\|DailyNotification"
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state message -->
|
<!-- Empty state message -->
|
||||||
<li v-if="hasNoEntities" :class="emptyStateClasses">
|
<li v-if="entities.length === 0" :class="emptyStateClasses">
|
||||||
{{ emptyStateMessage }}
|
{{ emptyStateMessage }}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
<li
|
<li
|
||||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
>
|
>
|
||||||
Everyone
|
Everyone Else
|
||||||
</li>
|
</li>
|
||||||
<PersonCard
|
<PersonCard
|
||||||
v-for="person in alphabeticalContacts"
|
v-for="person in alphabeticalContacts"
|
||||||
@@ -164,10 +164,6 @@ import { Contact } from "../db/tables/contacts";
|
|||||||
import { PlanData } from "../interfaces/records";
|
import { PlanData } from "../interfaces/records";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
|
||||||
import { getHeaders } from "../libs/endorserServer";
|
|
||||||
import { logger } from "../utils/logger";
|
|
||||||
import { TIMEOUTS } from "@/utils/notify";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for infinite scroll configuration
|
* Constants for infinite scroll configuration
|
||||||
@@ -195,7 +191,6 @@ const RECENT_CONTACTS_COUNT = 3;
|
|||||||
ProjectCard,
|
ProjectCard,
|
||||||
SpecialEntityCard,
|
SpecialEntityCard,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
|
||||||
})
|
})
|
||||||
export default class EntityGrid extends Vue {
|
export default class EntityGrid extends Vue {
|
||||||
/** Type of entities to display */
|
/** Type of entities to display */
|
||||||
@@ -207,36 +202,15 @@ export default class EntityGrid extends Vue {
|
|||||||
isSearching = false;
|
isSearching = false;
|
||||||
searchTimeout: NodeJS.Timeout | null = null;
|
searchTimeout: NodeJS.Timeout | null = null;
|
||||||
filteredEntities: Contact[] | PlanData[] = [];
|
filteredEntities: Contact[] | PlanData[] = [];
|
||||||
searchBeforeId: string | undefined = undefined;
|
|
||||||
isLoadingSearchMore = false;
|
|
||||||
|
|
||||||
// API server for project searches
|
|
||||||
apiServer = "";
|
|
||||||
|
|
||||||
// Internal project state (when entities prop not provided for projects)
|
|
||||||
allProjects: PlanData[] = [];
|
|
||||||
loadBeforeId: string | undefined = undefined;
|
|
||||||
isLoadingProjects = false;
|
|
||||||
|
|
||||||
// Infinite scroll state
|
// Infinite scroll state
|
||||||
displayedCount = INITIAL_BATCH_SIZE;
|
displayedCount = INITIAL_BATCH_SIZE;
|
||||||
infiniteScrollReset?: () => void;
|
infiniteScrollReset?: () => void;
|
||||||
scrollContainer?: HTMLElement;
|
scrollContainer?: HTMLElement;
|
||||||
|
|
||||||
/**
|
/** Array of entities to display */
|
||||||
* Array of entities to display
|
@Prop({ required: true })
|
||||||
*
|
entities!: Contact[] | PlanData[];
|
||||||
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
|
|
||||||
* Use $contactsByDateAdded() to ensure all contacts are included.
|
|
||||||
* Client-side filtering assumes the complete list is available.
|
|
||||||
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
|
|
||||||
* (newest first) for the "Recently Added" section to display correctly.
|
|
||||||
*
|
|
||||||
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
|
|
||||||
* projects internally from the API server. If provided, uses the provided list.
|
|
||||||
*/
|
|
||||||
@Prop({ required: false })
|
|
||||||
entities?: Contact[] | PlanData[];
|
|
||||||
|
|
||||||
/** Active user's DID */
|
/** Active user's DID */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
@@ -308,33 +282,6 @@ export default class EntityGrid extends Vue {
|
|||||||
return "text-xs text-slate-500 italic col-span-full";
|
return "text-xs text-slate-500 italic col-span-full";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if there are no entities to display
|
|
||||||
*/
|
|
||||||
get hasNoEntities(): boolean {
|
|
||||||
if (this.entityType === "projects") {
|
|
||||||
// For projects: check internal state if no entities prop, otherwise check prop
|
|
||||||
const projectsToCheck = this.entities || this.allProjects;
|
|
||||||
return projectsToCheck.length === 0;
|
|
||||||
} else {
|
|
||||||
// For people: entities prop is required
|
|
||||||
return !this.entities || this.entities.length === 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the entities array to use (prop or internal state)
|
|
||||||
*/
|
|
||||||
get entitiesToUse(): Contact[] | PlanData[] {
|
|
||||||
if (this.entityType === "projects") {
|
|
||||||
// For projects: use prop if provided, otherwise use internal state
|
|
||||||
return this.entities || this.allProjects;
|
|
||||||
} else {
|
|
||||||
// For people: entities prop is required
|
|
||||||
return this.entities || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
|
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
|
||||||
* When searching, returns filtered results with infinite scroll applied
|
* When searching, returns filtered results with infinite scroll applied
|
||||||
@@ -347,12 +294,12 @@ export default class EntityGrid extends Vue {
|
|||||||
|
|
||||||
// If custom function provided, use it (disables infinite scroll)
|
// If custom function provided, use it (disables infinite scroll)
|
||||||
if (this.displayEntitiesFunction) {
|
if (this.displayEntitiesFunction) {
|
||||||
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
|
return this.displayEntitiesFunction(this.entities, this.entityType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: projects use infinite scroll
|
// Default: projects use infinite scroll
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
return (this.entities as PlanData[]).slice(0, this.displayedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
||||||
@@ -360,21 +307,14 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the most recently added contacts (when showing contacts and not searching)
|
* Get the 3 most recently added contacts (when showing contacts and not searching)
|
||||||
*
|
|
||||||
* NOTE: This assumes entities are already sorted by date added (newest first).
|
|
||||||
* See the entities prop documentation for details on using $contactsByDateAdded().
|
|
||||||
*/
|
*/
|
||||||
get recentContacts(): Contact[] {
|
get recentContacts(): Contact[] {
|
||||||
if (
|
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
||||||
this.entityType !== "people" ||
|
|
||||||
this.searchTerm.trim() ||
|
|
||||||
!this.entities
|
|
||||||
) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Entities are already sorted by date added (newest first)
|
// Entities are already sorted by date added (newest first)
|
||||||
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
|
return (this.entities as Contact[]).slice(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -382,23 +322,19 @@ export default class EntityGrid extends Vue {
|
|||||||
* Uses infinite scroll to control how many are displayed
|
* Uses infinite scroll to control how many are displayed
|
||||||
*/
|
*/
|
||||||
get alphabeticalContacts(): Contact[] {
|
get alphabeticalContacts(): Contact[] {
|
||||||
if (
|
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
||||||
this.entityType !== "people" ||
|
|
||||||
this.searchTerm.trim() ||
|
|
||||||
!this.entities
|
|
||||||
) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Skip the first few (recent contacts) and sort the rest alphabetically
|
// Skip the first 3 (recent contacts) and sort the rest alphabetically
|
||||||
// Create a copy to avoid mutating the original array
|
// Create a copy to avoid mutating the original array
|
||||||
const remaining = this.entities as Contact[];
|
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
|
||||||
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
|
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
|
||||||
// Sort alphabetically by name, falling back to DID if name is missing
|
// Sort alphabetically by name, falling back to DID if name is missing
|
||||||
const nameA = (a.name || a.did).toLowerCase();
|
const nameA = (a.name || a.did).toLowerCase();
|
||||||
const nameB = (b.name || b.did).toLowerCase();
|
const nameB = (b.name || b.did).toLowerCase();
|
||||||
return nameA.localeCompare(nameB);
|
return nameA.localeCompare(nameB);
|
||||||
});
|
});
|
||||||
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
|
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
|
||||||
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
||||||
return sorted.slice(0, toShow);
|
return sorted.slice(0, toShow);
|
||||||
}
|
}
|
||||||
@@ -507,28 +443,47 @@ export default class EntityGrid extends Vue {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform the actual search
|
* Perform the actual search
|
||||||
* Routes to server-side search for projects or client-side filtering for contacts
|
|
||||||
*/
|
*/
|
||||||
async performSearch(): Promise<void> {
|
async performSearch(): Promise<void> {
|
||||||
if (!this.searchTerm.trim()) {
|
if (!this.searchTerm.trim()) {
|
||||||
this.filteredEntities = [];
|
this.filteredEntities = [];
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
this.searchBeforeId = undefined;
|
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
this.searchBeforeId = undefined; // Reset pagination for new search
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.entityType === "projects") {
|
// Simulate async search (in case we need to add API calls later)
|
||||||
// Server-side search for projects (initial load, no beforeId)
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
|
||||||
await this.fetchProjects(undefined, searchLower);
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (this.entityType === "people") {
|
||||||
|
this.filteredEntities = (this.entities as Contact[])
|
||||||
|
.filter((contact: Contact) => {
|
||||||
|
const name = contact.name?.toLowerCase() || "";
|
||||||
|
const did = contact.did.toLowerCase();
|
||||||
|
return name.includes(searchLower) || did.includes(searchLower);
|
||||||
|
})
|
||||||
|
.sort((a: Contact, b: Contact) => {
|
||||||
|
// Sort alphabetically by name, falling back to DID if name is missing
|
||||||
|
const nameA = (a.name || a.did).toLowerCase();
|
||||||
|
const nameB = (b.name || b.did).toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Client-side filtering for contacts (complete list)
|
this.filteredEntities = (this.entities as PlanData[])
|
||||||
await this.performContactSearch();
|
.filter((project: PlanData) => {
|
||||||
|
const name = project.name?.toLowerCase() || "";
|
||||||
|
const handleId = project.handleId.toLowerCase();
|
||||||
|
return name.includes(searchLower) || handleId.includes(searchLower);
|
||||||
|
})
|
||||||
|
.sort((a: PlanData, b: PlanData) => {
|
||||||
|
// Sort alphabetically by name
|
||||||
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset displayed count when search completes
|
// Reset displayed count when search completes
|
||||||
@@ -539,194 +494,6 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch projects from API server
|
|
||||||
* Unified method for both loading all projects and searching projects.
|
|
||||||
* If claimContents is provided, performs search and updates filteredEntities.
|
|
||||||
* If claimContents is not provided, loads all projects and updates allProjects.
|
|
||||||
*
|
|
||||||
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
|
||||||
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
|
|
||||||
*/
|
|
||||||
async fetchProjects(
|
|
||||||
beforeId?: string,
|
|
||||||
claimContents?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.apiServer) {
|
|
||||||
if (claimContents) {
|
|
||||||
this.filteredEntities = [];
|
|
||||||
} else {
|
|
||||||
this.allProjects = [];
|
|
||||||
}
|
|
||||||
if (this.notify) {
|
|
||||||
this.notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "API server not configured",
|
|
||||||
},
|
|
||||||
TIMEOUTS.SHORT,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSearch = !!claimContents;
|
|
||||||
let url = `${this.apiServer}/api/v2/report/plans`;
|
|
||||||
|
|
||||||
// Build query parameters
|
|
||||||
const params: string[] = [];
|
|
||||||
if (claimContents) {
|
|
||||||
params.push(
|
|
||||||
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (beforeId) {
|
|
||||||
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
|
|
||||||
}
|
|
||||||
if (params.length > 0) {
|
|
||||||
url += `?${params.join("&")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: await getHeaders(this.activeDid),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error(
|
|
||||||
isSearch ? "Failed to search projects" : "Failed to load projects",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await response.json();
|
|
||||||
if (results.data) {
|
|
||||||
const newProjects = results.data.map(
|
|
||||||
(plan: PlanData & { rowId?: string }) => ({
|
|
||||||
...plan,
|
|
||||||
rowId: plan.rowId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isSearch) {
|
|
||||||
// Search mode: update filteredEntities
|
|
||||||
if (beforeId) {
|
|
||||||
// Pagination: append new projects to existing search results
|
|
||||||
this.filteredEntities.push(...newProjects);
|
|
||||||
} else {
|
|
||||||
// Initial search: replace array
|
|
||||||
this.filteredEntities = newProjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update searchBeforeId for next pagination
|
|
||||||
if (newProjects.length > 0) {
|
|
||||||
const lastProject = newProjects[newProjects.length - 1];
|
|
||||||
this.searchBeforeId = lastProject.rowId || undefined;
|
|
||||||
} else {
|
|
||||||
this.searchBeforeId = undefined; // No more results
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Load mode: update allProjects
|
|
||||||
if (beforeId) {
|
|
||||||
// Pagination: append new projects
|
|
||||||
this.allProjects.push(...newProjects);
|
|
||||||
} else {
|
|
||||||
// Initial load: replace array
|
|
||||||
this.allProjects = newProjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update loadBeforeId for next pagination
|
|
||||||
if (newProjects.length > 0) {
|
|
||||||
const lastProject = newProjects[newProjects.length - 1];
|
|
||||||
this.loadBeforeId = lastProject.rowId || undefined;
|
|
||||||
} else {
|
|
||||||
this.loadBeforeId = undefined; // No more results
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No data in response
|
|
||||||
if (isSearch) {
|
|
||||||
if (!beforeId) {
|
|
||||||
// Only clear on initial search, not pagination
|
|
||||||
this.filteredEntities = [];
|
|
||||||
}
|
|
||||||
this.searchBeforeId = undefined;
|
|
||||||
} else {
|
|
||||||
if (!beforeId) {
|
|
||||||
// Only clear on initial load, not pagination
|
|
||||||
this.allProjects = [];
|
|
||||||
}
|
|
||||||
this.loadBeforeId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Error ${isSearch ? "searching" : "loading"} projects:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
if (isSearch) {
|
|
||||||
if (!beforeId) {
|
|
||||||
// Only clear on initial search error, not pagination error
|
|
||||||
this.filteredEntities = [];
|
|
||||||
}
|
|
||||||
this.searchBeforeId = undefined;
|
|
||||||
} else {
|
|
||||||
if (!beforeId) {
|
|
||||||
// Only clear on initial load error, not pagination error
|
|
||||||
this.allProjects = [];
|
|
||||||
}
|
|
||||||
this.loadBeforeId = undefined;
|
|
||||||
}
|
|
||||||
if (this.notify) {
|
|
||||||
this.notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: isSearch
|
|
||||||
? "Failed to search projects. Please try again."
|
|
||||||
: "Failed to load projects. Please try again.",
|
|
||||||
},
|
|
||||||
TIMEOUTS.STANDARD,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-side contact search
|
|
||||||
* Assumes entities prop contains complete contact list from local database
|
|
||||||
*/
|
|
||||||
async performContactSearch(): Promise<void> {
|
|
||||||
if (!this.entities) {
|
|
||||||
this.filteredEntities = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate async (for consistency with project search)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
|
||||||
|
|
||||||
this.filteredEntities = (this.entities as Contact[])
|
|
||||||
.filter((contact: Contact) => {
|
|
||||||
const name = contact.name?.toLowerCase() || "";
|
|
||||||
const did = contact.did.toLowerCase();
|
|
||||||
return name.includes(searchLower) || did.includes(searchLower);
|
|
||||||
})
|
|
||||||
.sort((a: Contact, b: Contact) => {
|
|
||||||
// Sort alphabetically by name, falling back to DID if name is missing
|
|
||||||
const nameA = (a.name || a.did).toLowerCase();
|
|
||||||
const nameB = (b.name || b.did).toLowerCase();
|
|
||||||
return nameA.localeCompare(nameB);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Contacts don't need pagination (complete list)
|
|
||||||
this.searchBeforeId = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the search
|
* Clear the search
|
||||||
*/
|
*/
|
||||||
@@ -735,7 +502,6 @@ export default class EntityGrid extends Vue {
|
|||||||
this.filteredEntities = [];
|
this.filteredEntities = [];
|
||||||
this.isSearching = false;
|
this.isSearching = false;
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
this.searchBeforeId = undefined;
|
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
|
|
||||||
// Clear any pending timeout
|
// Clear any pending timeout
|
||||||
@@ -755,161 +521,35 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.searchTerm.trim()) {
|
if (this.searchTerm.trim()) {
|
||||||
// Search mode: check if more results available
|
// Search mode: check filtered entities
|
||||||
if (this.entityType === "projects") {
|
return this.displayedCount < this.filteredEntities.length;
|
||||||
// Projects: can load more if:
|
|
||||||
// 1. We have more already-loaded results to show, OR
|
|
||||||
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
|
|
||||||
const hasMoreLoaded =
|
|
||||||
this.displayedCount < this.filteredEntities.length;
|
|
||||||
const canLoadMoreFromServer =
|
|
||||||
this.displayedCount >= this.filteredEntities.length &&
|
|
||||||
!!this.searchBeforeId &&
|
|
||||||
!this.isLoadingSearchMore;
|
|
||||||
return hasMoreLoaded || canLoadMoreFromServer;
|
|
||||||
} else {
|
|
||||||
// Contacts: client-side filtering returns all results at once
|
|
||||||
return this.displayedCount < this.filteredEntities.length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-search mode
|
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
// Projects: check internal state or prop
|
// Projects: check if more available
|
||||||
const projectsToCheck = this.entities || this.allProjects;
|
return this.displayedCount < this.entities.length;
|
||||||
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
|
||||||
|
|
||||||
// Can load more if:
|
|
||||||
// 1. We have more already-loaded results to show, OR
|
|
||||||
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
|
|
||||||
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
|
|
||||||
const canLoadMoreFromServer =
|
|
||||||
!this.entities &&
|
|
||||||
this.displayedCount >= projectsToCheck.length &&
|
|
||||||
!!beforeId &&
|
|
||||||
!this.isLoadingProjects;
|
|
||||||
|
|
||||||
return hasMoreLoaded || canLoadMoreFromServer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// People: check if more alphabetical contacts available
|
// People: check if more alphabetical contacts available
|
||||||
// Total available = recent + all alphabetical
|
// Total available = 3 recent + all alphabetical
|
||||||
if (!this.entities) {
|
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
|
||||||
return false;
|
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
|
||||||
}
|
|
||||||
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
|
|
||||||
return this.displayedCount < totalAvailable;
|
return this.displayedCount < totalAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize infinite scroll on mount
|
* Initialize infinite scroll on mount
|
||||||
*/
|
*/
|
||||||
async mounted(): Promise<void> {
|
mounted(): void {
|
||||||
// Load apiServer for project searches/loads
|
|
||||||
if (this.entityType === "projects") {
|
|
||||||
const settings = await this.$accountSettings();
|
|
||||||
this.apiServer = settings.apiServer || "";
|
|
||||||
|
|
||||||
// Load projects on mount if entities prop not provided
|
|
||||||
if (!this.entities && this.apiServer) {
|
|
||||||
this.isLoadingProjects = true;
|
|
||||||
try {
|
|
||||||
await this.fetchProjects();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error loading projects on mount:", error);
|
|
||||||
} finally {
|
|
||||||
this.isLoadingProjects = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate entities prop for people
|
|
||||||
if (this.entityType === "people" && !this.entities) {
|
|
||||||
logger.error(
|
|
||||||
"EntityGrid: entities prop is required when entityType is 'people'",
|
|
||||||
);
|
|
||||||
if (this.notify) {
|
|
||||||
this.notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Contacts data is required but not provided.",
|
|
||||||
},
|
|
||||||
TIMEOUTS.SHORT,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const container = this.$refs.scrollContainer as HTMLElement;
|
const container = this.$refs.scrollContainer as HTMLElement;
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
const { reset } = useInfiniteScroll(
|
const { reset } = useInfiniteScroll(
|
||||||
container,
|
container,
|
||||||
async () => {
|
() => {
|
||||||
// Search mode: handle search pagination
|
// Load more: increment displayedCount
|
||||||
if (this.searchTerm.trim()) {
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
if (this.entityType === "projects") {
|
|
||||||
// Projects: load more search results if available
|
|
||||||
if (
|
|
||||||
this.displayedCount >= this.filteredEntities.length &&
|
|
||||||
this.searchBeforeId &&
|
|
||||||
!this.isLoadingSearchMore
|
|
||||||
) {
|
|
||||||
this.isLoadingSearchMore = true;
|
|
||||||
try {
|
|
||||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
|
||||||
await this.fetchProjects(this.searchBeforeId, searchLower);
|
|
||||||
// After loading more, reset scroll state to allow further loading
|
|
||||||
this.infiniteScrollReset?.();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error loading more search results:", error);
|
|
||||||
// Error already handled in fetchProjects
|
|
||||||
} finally {
|
|
||||||
this.isLoadingSearchMore = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Show more from already-loaded search results
|
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Contacts: show more from already-filtered results
|
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-search mode
|
|
||||||
if (this.entityType === "projects") {
|
|
||||||
const projectsToCheck = this.entities || this.allProjects;
|
|
||||||
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
|
||||||
|
|
||||||
// If using internal state and need to load more from server
|
|
||||||
if (
|
|
||||||
!this.entities &&
|
|
||||||
this.displayedCount >= projectsToCheck.length &&
|
|
||||||
beforeId &&
|
|
||||||
!this.isLoadingProjects
|
|
||||||
) {
|
|
||||||
this.isLoadingProjects = true;
|
|
||||||
try {
|
|
||||||
await this.fetchProjects(beforeId);
|
|
||||||
// After loading more, reset scroll state to allow further loading
|
|
||||||
this.infiniteScrollReset?.();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error loading more projects:", error);
|
|
||||||
// Error already handled in fetchProjects
|
|
||||||
} finally {
|
|
||||||
this.isLoadingProjects = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal case: increment displayedCount to show more from memory
|
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// People: increment displayedCount to show more from memory
|
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
distance: 50, // pixels from bottom
|
distance: 50, // pixels from bottom
|
||||||
@@ -935,35 +575,21 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch for changes in search term to reset displayed count and pagination
|
* Watch for changes in search term to reset displayed count
|
||||||
*/
|
*/
|
||||||
@Watch("searchTerm")
|
@Watch("searchTerm")
|
||||||
onSearchTermChange(): void {
|
onSearchTermChange(): void {
|
||||||
// Reset displayed count and pagination when search term changes
|
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
this.searchBeforeId = undefined;
|
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch for changes in entities prop to clear search and reset displayed count
|
* Watch for changes in entities prop to reset displayed count
|
||||||
*/
|
*/
|
||||||
@Watch("entities")
|
@Watch("entities")
|
||||||
onEntitiesChange(): void {
|
onEntitiesChange(): void {
|
||||||
// Clear search when entities change (fresh dialog open)
|
|
||||||
if (this.searchTerm) {
|
|
||||||
this.searchTerm = "";
|
|
||||||
this.filteredEntities = [];
|
|
||||||
this.searchBeforeId = undefined;
|
|
||||||
}
|
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
|
|
||||||
// For projects: if entities prop is provided, clear internal state
|
|
||||||
if (this.entityType === "projects" && this.entities) {
|
|
||||||
this.allProjects = [];
|
|
||||||
this.loadBeforeId = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ properties * * @author Matthew Raymer */
|
|||||||
|
|
||||||
<EntityGrid
|
<EntityGrid
|
||||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||||
:entities="shouldShowProjects ? projects || undefined : allContacts"
|
:entities="shouldShowProjects ? projects : allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
@@ -94,9 +94,9 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop({ default: false })
|
@Prop({ default: false })
|
||||||
isFromProjectView!: boolean;
|
isFromProjectView!: boolean;
|
||||||
|
|
||||||
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
|
/** Array of available projects */
|
||||||
@Prop({ required: false })
|
@Prop({ required: true })
|
||||||
projects?: PlanData[];
|
projects!: PlanData[];
|
||||||
|
|
||||||
/** Array of available contacts */
|
/** Array of available contacts */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
giverEntityType === 'project' || recipientEntityType === 'project'
|
giverEntityType === 'project' || recipientEntityType === 'project'
|
||||||
"
|
"
|
||||||
:is-from-project-view="isFromProjectView"
|
:is-from-project-view="isFromProjectView"
|
||||||
|
:projects="projects"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
@@ -67,6 +68,7 @@ import {
|
|||||||
createAndSubmitGive,
|
createAndSubmitGive,
|
||||||
didInfo,
|
didInfo,
|
||||||
serverMessageForUser,
|
serverMessageForUser,
|
||||||
|
getHeaders,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import * as libsUtil from "../libs/util";
|
import * as libsUtil from "../libs/util";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
@@ -132,6 +134,7 @@ export default class GiftedDialog extends Vue {
|
|||||||
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
||||||
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||||
offerId = "";
|
offerId = "";
|
||||||
|
projects: PlanData[] = [];
|
||||||
prompt = "";
|
prompt = "";
|
||||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||||
stepType = "giver";
|
stepType = "giver";
|
||||||
@@ -231,6 +234,16 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.allContacts = await this.$contactsByDateAdded();
|
this.allContacts = await this.$contactsByDateAdded();
|
||||||
|
|
||||||
this.allMyDids = await retrieveAccountDids();
|
this.allMyDids = await retrieveAccountDids();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.giverEntityType === "project" ||
|
||||||
|
this.recipientEntityType === "project"
|
||||||
|
) {
|
||||||
|
await this.loadProjects();
|
||||||
|
} else {
|
||||||
|
// Clear projects array when not needed
|
||||||
|
this.projects = [];
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.error("Error retrieving settings from database:", err);
|
logger.error("Error retrieving settings from database:", err);
|
||||||
this.safeNotify.error(
|
this.safeNotify.error(
|
||||||
@@ -476,6 +489,27 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.firstStep = false;
|
this.firstStep = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadProjects() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
|
||||||
|
method: "GET",
|
||||||
|
headers: await getHeaders(this.activeDid),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error("Failed to load projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
if (results.data) {
|
||||||
|
this.projects = results.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading projects:", error);
|
||||||
|
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
selectProject(project: PlanData) {
|
selectProject(project: PlanData) {
|
||||||
this.giver = {
|
this.giver = {
|
||||||
did: project.handleId,
|
did: project.handleId,
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="visible" class="dialog-overlay">
|
|
||||||
<div class="dialog">
|
|
||||||
<!-- Header -->
|
|
||||||
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
|
|
||||||
|
|
||||||
<!-- EntityGrid for projects -->
|
|
||||||
<EntityGrid
|
|
||||||
:entity-type="'projects'"
|
|
||||||
:active-did="activeDid"
|
|
||||||
:all-my-dids="allMyDids"
|
|
||||||
:all-contacts="allContacts"
|
|
||||||
:conflict-checker="() => false"
|
|
||||||
:show-you-entity="false"
|
|
||||||
:show-unnamed-entity="false"
|
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="'project'"
|
|
||||||
@entity-selected="handleEntitySelected"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Cancel Button -->
|
|
||||||
<div class="flex gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
|
||||||
@click="handleCancel"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
|
||||||
import EntityGrid from "./EntityGrid.vue";
|
|
||||||
import { Contact } from "../db/tables/contacts";
|
|
||||||
import { PlanData } from "../interfaces/records";
|
|
||||||
import { NotificationIface } from "../constants/app";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - EntityGrid integration for project selection
|
|
||||||
* - No special entities (You, Unnamed)
|
|
||||||
* - Immediate assignment on project selection
|
|
||||||
* - Cancel button to close without selection
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
components: {
|
|
||||||
EntityGrid,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class MeetingProjectDialog extends Vue {
|
|
||||||
/** Whether the dialog is visible */
|
|
||||||
visible = false;
|
|
||||||
|
|
||||||
/** Active user's DID */
|
|
||||||
@Prop({ required: true })
|
|
||||||
activeDid!: string;
|
|
||||||
|
|
||||||
/** All user's DIDs */
|
|
||||||
@Prop({ required: true })
|
|
||||||
allMyDids!: string[];
|
|
||||||
|
|
||||||
/** All contacts */
|
|
||||||
@Prop({ required: true })
|
|
||||||
allContacts!: Contact[];
|
|
||||||
|
|
||||||
/** Notification function from parent component */
|
|
||||||
@Prop()
|
|
||||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle entity selection from EntityGrid
|
|
||||||
* Immediately assigns the selected project and closes the dialog
|
|
||||||
*/
|
|
||||||
handleEntitySelected(event: {
|
|
||||||
type: "person" | "project";
|
|
||||||
data: Contact | PlanData;
|
|
||||||
}) {
|
|
||||||
const project = event.data as PlanData;
|
|
||||||
this.emitAssign(project);
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle cancel button click
|
|
||||||
*/
|
|
||||||
handleCancel(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the dialog
|
|
||||||
*/
|
|
||||||
open(): void {
|
|
||||||
this.visible = true;
|
|
||||||
this.emitOpen();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the dialog
|
|
||||||
*/
|
|
||||||
close(): void {
|
|
||||||
this.visible = false;
|
|
||||||
this.emitClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit methods using @Emit decorator
|
|
||||||
|
|
||||||
@Emit("assign")
|
|
||||||
emitAssign(project: PlanData): PlanData {
|
|
||||||
return project;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Emit("open")
|
|
||||||
emitOpen(): void {
|
|
||||||
// Emit when dialog opens
|
|
||||||
}
|
|
||||||
|
|
||||||
@Emit("close")
|
|
||||||
emitClose(): void {
|
|
||||||
// Emit when dialog closes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -8,7 +8,7 @@ issuer information. * * @author Matthew Raymer */
|
|||||||
>
|
>
|
||||||
<ProjectIcon
|
<ProjectIcon
|
||||||
:entity-id="project.handleId"
|
:entity-id="project.handleId"
|
||||||
:icon-size="30"
|
:icon-size="48"
|
||||||
:image-url="project.image"
|
:image-url="project.image"
|
||||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="visible" class="dialog-overlay">
|
|
||||||
<div class="dialog">
|
|
||||||
<!-- Header -->
|
|
||||||
<h2 class="text-lg font-semibold leading-[1.25] mb-4">
|
|
||||||
Select Representative
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- EntityGrid for contacts -->
|
|
||||||
<EntityGrid
|
|
||||||
:entity-type="'people'"
|
|
||||||
:entities="allContacts"
|
|
||||||
:active-did="activeDid"
|
|
||||||
:all-my-dids="allMyDids"
|
|
||||||
:all-contacts="allContacts"
|
|
||||||
:conflict-checker="() => false"
|
|
||||||
:show-you-entity="false"
|
|
||||||
:show-unnamed-entity="false"
|
|
||||||
:notify="notify"
|
|
||||||
:conflict-context="'representative'"
|
|
||||||
@entity-selected="handleEntitySelected"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Cancel Button -->
|
|
||||||
<div class="flex gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
|
||||||
@click="handleCancel"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
|
||||||
import EntityGrid from "./EntityGrid.vue";
|
|
||||||
import { Contact } from "../db/tables/contacts";
|
|
||||||
import { NotificationIface } from "../constants/app";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ProjectRepresentativeDialog - Dialog for selecting an authorized representative
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - EntityGrid integration for contact selection
|
|
||||||
* - No special entities (You, Unnamed)
|
|
||||||
* - Immediate assignment on contact selection
|
|
||||||
* - Cancel button to close without selection
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
components: {
|
|
||||||
EntityGrid,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class ProjectRepresentativeDialog extends Vue {
|
|
||||||
/** Whether the dialog is visible */
|
|
||||||
visible = false;
|
|
||||||
|
|
||||||
/** Array of available contacts */
|
|
||||||
@Prop({ required: true })
|
|
||||||
allContacts!: Contact[];
|
|
||||||
|
|
||||||
/** Active user's DID */
|
|
||||||
@Prop({ required: true })
|
|
||||||
activeDid!: string;
|
|
||||||
|
|
||||||
/** All user's DIDs */
|
|
||||||
@Prop({ required: true })
|
|
||||||
allMyDids!: string[];
|
|
||||||
|
|
||||||
/** Notification function from parent component */
|
|
||||||
@Prop()
|
|
||||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle entity selection from EntityGrid
|
|
||||||
* Immediately assigns the selected contact and closes the dialog
|
|
||||||
*/
|
|
||||||
handleEntitySelected(event: { type: "person" | "project"; data: Contact }) {
|
|
||||||
const contact = event.data as Contact;
|
|
||||||
this.emitAssign(contact);
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle cancel button click
|
|
||||||
*/
|
|
||||||
handleCancel(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the dialog
|
|
||||||
*/
|
|
||||||
open(): void {
|
|
||||||
this.visible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the dialog
|
|
||||||
*/
|
|
||||||
close(): void {
|
|
||||||
this.visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit methods using @Emit decorator
|
|
||||||
|
|
||||||
@Emit("assign")
|
|
||||||
emitAssign(contact: Contact): Contact {
|
|
||||||
return contact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
781
src/components/notifications/DailyNotificationSection.vue
Normal file
781
src/components/notifications/DailyNotificationSection.vue
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="notificationsSupported"
|
||||||
|
id="sectionDailyNotifications"
|
||||||
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||||
|
aria-labelledby="dailyNotificationsHeading"
|
||||||
|
>
|
||||||
|
<h2 id="dailyNotificationsHeading" class="mb-2 font-bold">
|
||||||
|
Daily Notifications
|
||||||
|
<button
|
||||||
|
class="text-slate-400 fa-fw cursor-pointer"
|
||||||
|
aria-label="Learn more about native notifications"
|
||||||
|
@click.stop="showNativeNotificationInfo"
|
||||||
|
>
|
||||||
|
<font-awesome icon="circle-question" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>Daily Notification</div>
|
||||||
|
<!-- Toggle switch -->
|
||||||
|
<div
|
||||||
|
class="relative ml-2 cursor-pointer"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="nativeNotificationEnabled"
|
||||||
|
:aria-label="
|
||||||
|
nativeNotificationEnabled
|
||||||
|
? 'Disable daily notifications'
|
||||||
|
: 'Enable daily notifications'
|
||||||
|
"
|
||||||
|
tabindex="0"
|
||||||
|
@click="toggleNativeNotification"
|
||||||
|
>
|
||||||
|
<!-- input -->
|
||||||
|
<input
|
||||||
|
:checked="nativeNotificationEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
tabindex="-1"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<!-- line -->
|
||||||
|
<div
|
||||||
|
class="block bg-slate-500 w-14 h-8 rounded-full transition"
|
||||||
|
:class="{
|
||||||
|
'bg-blue-600': nativeNotificationEnabled,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
<!-- dot -->
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
:class="{
|
||||||
|
'left-7 bg-white': nativeNotificationEnabled,
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show "Open Settings" button when permissions are denied -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
notificationsSupported &&
|
||||||
|
notificationStatus &&
|
||||||
|
notificationStatus.permissions.notifications === 'denied'
|
||||||
|
"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-2 bg-blue-600 text-white rounded text-sm font-medium"
|
||||||
|
@click="openNotificationSettings"
|
||||||
|
>
|
||||||
|
Open Settings
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-slate-500 mt-1 text-center">
|
||||||
|
Enable notifications in Settings > App info > Notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time input section - show when enabled OR when no time is set -->
|
||||||
|
<div
|
||||||
|
v-if="nativeNotificationEnabled || !nativeNotificationTimeStorage"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="nativeNotificationEnabled"
|
||||||
|
class="flex items-center justify-between mb-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>Scheduled for:
|
||||||
|
<span v-if="nativeNotificationTime">{{
|
||||||
|
nativeNotificationTime
|
||||||
|
}}</span>
|
||||||
|
<span v-else class="text-slate-500">Not set</span></span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="text-blue-500 text-sm"
|
||||||
|
@click="editNativeNotificationTime"
|
||||||
|
>
|
||||||
|
{{ showTimeEdit ? "Cancel" : "Edit Time" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time input (shown when editing or when no time is set) -->
|
||||||
|
<div v-if="showTimeEdit || !nativeNotificationTimeStorage" class="mt-2">
|
||||||
|
<label class="block text-sm text-slate-600 mb-1">
|
||||||
|
Notification Time
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="nativeNotificationTimeStorage"
|
||||||
|
type="time"
|
||||||
|
class="rounded border border-slate-400 px-2 py-2"
|
||||||
|
@change="onTimeChange"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="showTimeEdit || nativeNotificationTimeStorage"
|
||||||
|
class="px-3 py-2 bg-blue-600 text-white rounded"
|
||||||
|
@click="saveTimeChange"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="!nativeNotificationTimeStorage"
|
||||||
|
class="text-xs text-slate-500 mt-1"
|
||||||
|
>
|
||||||
|
Set a time before enabling notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="mt-2 text-sm text-slate-500">Loading...</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* DailyNotificationSection Component
|
||||||
|
*
|
||||||
|
* A self-contained component for managing daily notification scheduling
|
||||||
|
* in AccountViewView. This component handles platform detection, permission
|
||||||
|
* requests, scheduling, and state management for daily notifications.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Platform capability detection (hides on unsupported platforms)
|
||||||
|
* - Permission request flow
|
||||||
|
* - Schedule/cancel notifications
|
||||||
|
* - Time editing with HTML5 time input
|
||||||
|
* - Settings persistence
|
||||||
|
* - Plugin state synchronization
|
||||||
|
*
|
||||||
|
* @author Generated for TimeSafari Daily Notification Integration
|
||||||
|
* @component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||||
|
import type {
|
||||||
|
NotificationStatus,
|
||||||
|
PermissionStatus,
|
||||||
|
} from "@/services/PlatformService";
|
||||||
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
|
import type { NotificationIface } from "@/constants/app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert 24-hour time format ("09:00") to 12-hour display format ("9:00 AM")
|
||||||
|
*/
|
||||||
|
function formatTimeForDisplay(time24: string): string {
|
||||||
|
if (!time24) return "";
|
||||||
|
const [hours, minutes] = time24.split(":");
|
||||||
|
const hourNum = parseInt(hours);
|
||||||
|
const isPM = hourNum >= 12;
|
||||||
|
const displayHour =
|
||||||
|
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||||
|
return `${displayHour}:${minutes} ${isPM ? "PM" : "AM"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
name: "DailyNotificationSection",
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class DailyNotificationSection extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
notificationsSupported: boolean = false;
|
||||||
|
nativeNotificationEnabled: boolean = false;
|
||||||
|
nativeNotificationTime: string = ""; // Display format: "9:00 AM"
|
||||||
|
nativeNotificationTimeStorage: string = ""; // Plugin format: "09:00"
|
||||||
|
nativeNotificationTitle: string = "Daily Update";
|
||||||
|
nativeNotificationMessage: string = "Your daily notification is ready!";
|
||||||
|
showTimeEdit: boolean = false;
|
||||||
|
loading: boolean = false;
|
||||||
|
notificationStatus: NotificationStatus | null = null;
|
||||||
|
|
||||||
|
// Notify helpers
|
||||||
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
async created(): Promise<void> {
|
||||||
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize component state on mount
|
||||||
|
* Checks platform support and syncs with plugin state
|
||||||
|
*
|
||||||
|
* **Token Refresh on Mount:**
|
||||||
|
* - Refreshes native fetcher configuration to ensure plugin has valid token
|
||||||
|
* - This handles cases where app was closed for extended periods
|
||||||
|
* - Token refresh happens automatically without user interaction
|
||||||
|
*
|
||||||
|
* **App Resume Listener:**
|
||||||
|
* - Listens for Capacitor 'resume' event to refresh token when app comes to foreground
|
||||||
|
* - Ensures plugin always has fresh token for background prefetch operations
|
||||||
|
* - Cleaned up in `beforeDestroy()` lifecycle hook
|
||||||
|
*/
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
await this.initializeState();
|
||||||
|
// Refresh native fetcher configuration on mount
|
||||||
|
// This ensures plugin has valid token even if app was closed for extended periods
|
||||||
|
await this.refreshNativeFetcherConfig();
|
||||||
|
// Listen for app resume events to refresh token when app comes to foreground
|
||||||
|
// This is part of the proactive token refresh strategy
|
||||||
|
document.addEventListener("resume", this.handleAppResume);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup on component destroy
|
||||||
|
*/
|
||||||
|
beforeDestroy(): void {
|
||||||
|
document.removeEventListener("resume", this.handleAppResume);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle app resume event - refresh native fetcher configuration
|
||||||
|
*
|
||||||
|
* This method is called when the app comes to foreground (via Capacitor 'resume' event).
|
||||||
|
* It proactively refreshes the JWT token to ensure the plugin has valid authentication
|
||||||
|
* for background prefetch operations.
|
||||||
|
*
|
||||||
|
* **Why refresh on resume?**
|
||||||
|
* - Tokens expire after 72 hours
|
||||||
|
* - App may have been closed for extended periods
|
||||||
|
* - Refreshing ensures plugin has valid token for next prefetch cycle
|
||||||
|
* - No user interaction required - happens automatically
|
||||||
|
*
|
||||||
|
* @see {@link refreshNativeFetcherConfig} For implementation details
|
||||||
|
*/
|
||||||
|
async handleAppResume(): Promise<void> {
|
||||||
|
logger.debug(
|
||||||
|
"[DailyNotificationSection] App resumed, refreshing native fetcher config",
|
||||||
|
);
|
||||||
|
await this.refreshNativeFetcherConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh native fetcher configuration with fresh JWT token
|
||||||
|
*
|
||||||
|
* This method ensures the daily notification plugin has a valid authentication token
|
||||||
|
* for background prefetch operations. It's called proactively to prevent token expiration
|
||||||
|
* issues during offline periods.
|
||||||
|
*
|
||||||
|
* **Refresh Triggers:**
|
||||||
|
* - Component mount (when notification settings page loads)
|
||||||
|
* - App resume (when app comes to foreground)
|
||||||
|
* - Notification enabled (when user enables daily notifications)
|
||||||
|
*
|
||||||
|
* **Token Refresh Strategy (Hybrid Approach):**
|
||||||
|
* - Tokens are valid for 72 hours (see `accessTokenForBackground`)
|
||||||
|
* - Tokens are refreshed proactively when app is already open
|
||||||
|
* - If token expires while offline, plugin uses cached content
|
||||||
|
* - Next time app opens, token is automatically refreshed
|
||||||
|
*
|
||||||
|
* **Why This Approach?**
|
||||||
|
* - No app wake-up required (tokens refresh when app is already open)
|
||||||
|
* - Works offline (72-hour validity supports extended offline periods)
|
||||||
|
* - Automatic (no user interaction required)
|
||||||
|
* - Includes starred plans (fetcher receives user's starred plans for prefetch)
|
||||||
|
* - Graceful degradation (if refresh fails, cached content still works)
|
||||||
|
*
|
||||||
|
* **Error Handling:**
|
||||||
|
* - Errors are logged but not shown to user (background operation)
|
||||||
|
* - Returns early if notifications not supported or disabled
|
||||||
|
* - Returns early if API server not configured
|
||||||
|
* - Failures don't interrupt user experience
|
||||||
|
*
|
||||||
|
* @returns Promise that resolves when refresh completes (or fails silently)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Called automatically on mount/resume
|
||||||
|
* await this.refreshNativeFetcherConfig();
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link CapacitorPlatformService.configureNativeFetcher} For token generation
|
||||||
|
* @see {@link accessTokenForBackground} For 72-hour token generation
|
||||||
|
*/
|
||||||
|
async refreshNativeFetcherConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
|
||||||
|
// Early return: Only refresh if notifications are supported and enabled
|
||||||
|
// This prevents unnecessary work when notifications aren't being used
|
||||||
|
if (!this.notificationsSupported || !this.nativeNotificationEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get settings for API server and starred plans
|
||||||
|
// API server tells plugin where to fetch content from
|
||||||
|
// Starred plans tell plugin which plans to prefetch
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const apiServer = settings.apiServer || "";
|
||||||
|
|
||||||
|
if (!apiServer) {
|
||||||
|
logger.warn(
|
||||||
|
"[DailyNotificationSection] No API server configured, skipping native fetcher refresh",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get starred plans from settings
|
||||||
|
// These are passed to the plugin so it knows which plans to prefetch
|
||||||
|
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||||
|
|
||||||
|
// Configure native fetcher with fresh token
|
||||||
|
// The jwt parameter is ignored - configureNativeFetcher generates it automatically
|
||||||
|
// This ensures we always have a fresh token with current expiration time
|
||||||
|
await platformService.configureNativeFetcher({
|
||||||
|
apiServer,
|
||||||
|
jwt: "", // Will be generated automatically by configureNativeFetcher
|
||||||
|
starredPlanHandleIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[DailyNotificationSection] Native fetcher configuration refreshed",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Don't show error to user - this is a background operation
|
||||||
|
// Failures are logged for debugging but don't interrupt user experience
|
||||||
|
// If refresh fails, plugin will use existing token (if still valid) or cached content
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Failed to refresh native fetcher config:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize component state
|
||||||
|
* Checks platform support and syncs with plugin state
|
||||||
|
*/
|
||||||
|
async initializeState(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"[DailyNotificationSection] Checking notification support...",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if notifications are supported on this platform
|
||||||
|
// This also verifies plugin availability (returns null if plugin unavailable)
|
||||||
|
const status = await platformService.getDailyNotificationStatus();
|
||||||
|
if (status === null) {
|
||||||
|
// Notifications not supported or plugin unavailable - don't initialize
|
||||||
|
this.notificationsSupported = false;
|
||||||
|
logger.warn(
|
||||||
|
"[DailyNotificationSection] Notifications not supported or plugin unavailable - section will be hidden",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"[DailyNotificationSection] Notifications supported, status:",
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.notificationsSupported = true;
|
||||||
|
this.notificationStatus = status;
|
||||||
|
|
||||||
|
// Plugin state is the source of truth
|
||||||
|
if (status.isScheduled && status.scheduledTime) {
|
||||||
|
// Plugin has a scheduled notification - sync UI to match
|
||||||
|
this.nativeNotificationEnabled = true;
|
||||||
|
this.nativeNotificationTimeStorage = status.scheduledTime;
|
||||||
|
this.nativeNotificationTime = formatTimeForDisplay(
|
||||||
|
status.scheduledTime,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No plugin schedule - UI defaults to disabled
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
this.nativeNotificationTimeStorage = "";
|
||||||
|
this.nativeNotificationTime = "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[DailyNotificationSection] Failed to initialize:", error);
|
||||||
|
this.notificationsSupported = false;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle notification on/off
|
||||||
|
*/
|
||||||
|
async toggleNativeNotification(): Promise<void> {
|
||||||
|
// Prevent multiple simultaneous toggles
|
||||||
|
if (this.loading) {
|
||||||
|
logger.warn(
|
||||||
|
"[DailyNotificationSection] Toggle ignored - operation in progress",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[DailyNotificationSection] Toggling notification: ${this.nativeNotificationEnabled} -> ${!this.nativeNotificationEnabled}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.nativeNotificationEnabled) {
|
||||||
|
await this.disableNativeNotification();
|
||||||
|
} else {
|
||||||
|
await this.enableNativeNotification();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable daily notification
|
||||||
|
*/
|
||||||
|
async enableNativeNotification(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
|
||||||
|
// Check if we have a time set
|
||||||
|
if (!this.nativeNotificationTimeStorage) {
|
||||||
|
this.notify.error(
|
||||||
|
"Please set a notification time first",
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions first - this also verifies plugin availability
|
||||||
|
let permissions: PermissionStatus | null;
|
||||||
|
try {
|
||||||
|
permissions = await platformService.checkNotificationPermissions();
|
||||||
|
logger.info(
|
||||||
|
`[DailyNotificationSection] Permission check result:`,
|
||||||
|
permissions,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Plugin may not be available or there's an error
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Failed to check permissions (plugin may be unavailable):",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Unable to check notification permissions. The notification plugin may not be installed.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions === null) {
|
||||||
|
// Platform doesn't support notifications or plugin unavailable
|
||||||
|
logger.warn(
|
||||||
|
"[DailyNotificationSection] Notifications not supported or plugin unavailable",
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Notifications are not supported on this platform or the plugin is not installed.",
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[DailyNotificationSection] Permission state: ${permissions.notifications}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If permissions are explicitly denied, don't try to request again
|
||||||
|
// (this prevents the plugin crash when handling denied permissions)
|
||||||
|
// Android won't show the dialog again if permissions are permanently denied
|
||||||
|
if (permissions.notifications === "denied") {
|
||||||
|
logger.warn(
|
||||||
|
"[DailyNotificationSection] Permissions already denied, directing user to settings",
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Notification permissions were denied. Tap 'Open Settings' to enable them.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only request if permissions are in "prompt" state (not denied, not granted)
|
||||||
|
// This ensures we only call requestPermissions when Android will actually show a dialog
|
||||||
|
if (permissions.notifications === "prompt") {
|
||||||
|
logger.info(
|
||||||
|
"[DailyNotificationSection] Permission state is 'prompt', requesting permissions...",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = await platformService.requestNotificationPermissions();
|
||||||
|
logger.info(
|
||||||
|
`[DailyNotificationSection] Permission request result:`,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
if (result === null) {
|
||||||
|
// Plugin unavailable or request failed
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Permission request returned null",
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Unable to request notification permissions. The plugin may not be available.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.notifications) {
|
||||||
|
// Permission request was denied
|
||||||
|
logger.warn(
|
||||||
|
"[DailyNotificationSection] Permission request denied by user",
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Notification permissions are required. Tap 'Open Settings' to enable them.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Permissions granted - continue
|
||||||
|
logger.info(
|
||||||
|
"[DailyNotificationSection] Permissions granted successfully",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Handle permission request errors (including plugin crashes)
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Permission request failed:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Unable to request notification permissions. Tap 'Open Settings' to enable them.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (permissions.notifications !== "granted") {
|
||||||
|
// Unexpected state - shouldn't happen, but handle gracefully
|
||||||
|
logger.warn(
|
||||||
|
`[DailyNotificationSection] Unexpected permission state: ${permissions.notifications}`,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Unable to determine notification permission status. Tap 'Open Settings' to check.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
logger.info("[DailyNotificationSection] Permissions already granted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions are granted - continue with scheduling
|
||||||
|
|
||||||
|
// Schedule notification via PlatformService
|
||||||
|
await platformService.scheduleDailyNotification({
|
||||||
|
time: this.nativeNotificationTimeStorage, // "09:00" in local time
|
||||||
|
title: this.nativeNotificationTitle,
|
||||||
|
body: this.nativeNotificationMessage,
|
||||||
|
sound: true,
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update UI state
|
||||||
|
this.nativeNotificationEnabled = true;
|
||||||
|
|
||||||
|
// Refresh native fetcher configuration with fresh token
|
||||||
|
// This ensures plugin has valid authentication when notifications are first enabled
|
||||||
|
// Token will be valid for 72 hours, supporting offline prefetch operations
|
||||||
|
await this.refreshNativeFetcherConfig();
|
||||||
|
|
||||||
|
this.notify.success(
|
||||||
|
"Daily notification scheduled successfully",
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Failed to enable notification:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Failed to schedule notification. Please try again.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable daily notification
|
||||||
|
*/
|
||||||
|
async disableNativeNotification(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
|
||||||
|
// Cancel notification via PlatformService
|
||||||
|
await platformService.cancelDailyNotification();
|
||||||
|
|
||||||
|
// Update UI state
|
||||||
|
this.nativeNotificationEnabled = false;
|
||||||
|
this.nativeNotificationTime = "";
|
||||||
|
this.nativeNotificationTimeStorage = "";
|
||||||
|
this.showTimeEdit = false;
|
||||||
|
|
||||||
|
this.notify.success("Daily notification disabled", TIMEOUTS.SHORT);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Failed to disable notification:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Failed to disable notification. Please try again.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show/hide time edit input
|
||||||
|
*/
|
||||||
|
editNativeNotificationTime(): void {
|
||||||
|
this.showTimeEdit = !this.showTimeEdit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle time input change
|
||||||
|
*/
|
||||||
|
onTimeChange(): void {
|
||||||
|
// Time is already in nativeNotificationTimeStorage via v-model
|
||||||
|
// Just update display format
|
||||||
|
if (this.nativeNotificationTimeStorage) {
|
||||||
|
this.nativeNotificationTime = formatTimeForDisplay(
|
||||||
|
this.nativeNotificationTimeStorage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save time change and update notification schedule
|
||||||
|
*/
|
||||||
|
async saveTimeChange(): Promise<void> {
|
||||||
|
if (!this.nativeNotificationTimeStorage) {
|
||||||
|
this.notify.error("Please select a time", TIMEOUTS.SHORT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display format
|
||||||
|
this.nativeNotificationTime = formatTimeForDisplay(
|
||||||
|
this.nativeNotificationTimeStorage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If notification is enabled, update the schedule
|
||||||
|
if (this.nativeNotificationEnabled) {
|
||||||
|
await this.updateNotificationTime(this.nativeNotificationTimeStorage);
|
||||||
|
} else {
|
||||||
|
// Just update local state (time preference stored in component)
|
||||||
|
this.showTimeEdit = false;
|
||||||
|
this.notify.success("Notification time saved", TIMEOUTS.SHORT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification time
|
||||||
|
* If notification is enabled, immediately updates the schedule
|
||||||
|
*/
|
||||||
|
async updateNotificationTime(newTime: string): Promise<void> {
|
||||||
|
// newTime is in "HH:mm" format from HTML5 time input
|
||||||
|
if (!this.nativeNotificationEnabled) {
|
||||||
|
// If notification is disabled, just update local state
|
||||||
|
this.nativeNotificationTimeStorage = newTime;
|
||||||
|
this.nativeNotificationTime = formatTimeForDisplay(newTime);
|
||||||
|
this.showTimeEdit = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification is enabled - update the schedule
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
|
||||||
|
// 1. Cancel existing notification
|
||||||
|
await platformService.cancelDailyNotification();
|
||||||
|
|
||||||
|
// 2. Schedule with new time
|
||||||
|
await platformService.scheduleDailyNotification({
|
||||||
|
time: newTime, // "09:00" in local time
|
||||||
|
title: this.nativeNotificationTitle,
|
||||||
|
body: this.nativeNotificationMessage,
|
||||||
|
sound: true,
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Update local state
|
||||||
|
this.nativeNotificationTimeStorage = newTime;
|
||||||
|
this.nativeNotificationTime = formatTimeForDisplay(newTime);
|
||||||
|
|
||||||
|
this.notify.success(
|
||||||
|
"Notification time updated successfully",
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
this.showTimeEdit = false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Failed to update notification time:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Failed to update notification time. Please try again.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show info dialog about native notifications
|
||||||
|
*/
|
||||||
|
showNativeNotificationInfo(): void {
|
||||||
|
// TODO: Implement info dialog or navigate to help page
|
||||||
|
this.notify.info(
|
||||||
|
"Daily notifications use your device's native notification system. They work even when the app is closed.",
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open app notification settings
|
||||||
|
*/
|
||||||
|
async openNotificationSettings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
const result = await platformService.openAppNotificationSettings();
|
||||||
|
if (result === null) {
|
||||||
|
this.notify.error(
|
||||||
|
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.notify.success("Opening notification settings...", TIMEOUTS.SHORT);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Failed to open notification settings:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dot {
|
||||||
|
transition: left 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -104,6 +104,71 @@ export const accessToken = async (did?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a longer-lived access token for background operations
|
||||||
|
*
|
||||||
|
* This function creates JWT tokens with extended validity (default 72 hours) for use
|
||||||
|
* in background prefetch operations. The longer expiration period allows the daily
|
||||||
|
* notification plugin to work offline for extended periods without requiring the app
|
||||||
|
* to be in the foreground to refresh tokens.
|
||||||
|
*
|
||||||
|
* **Token Refresh Strategy (Hybrid Approach):**
|
||||||
|
* - Tokens are valid for 72 hours (configurable)
|
||||||
|
* - Tokens are refreshed proactively when:
|
||||||
|
* - App comes to foreground (via Capacitor 'resume' event)
|
||||||
|
* - Component mounts (DailyNotificationSection)
|
||||||
|
* - Notifications are enabled
|
||||||
|
* - If token expires while offline, plugin uses cached content
|
||||||
|
* - Next time app opens, token is automatically refreshed
|
||||||
|
*
|
||||||
|
* **Why 72 Hours?**
|
||||||
|
* - Balances security (read-only prefetch operations) with offline capability
|
||||||
|
* - Reduces need for app to wake itself for token refresh
|
||||||
|
* - Allows plugin to work offline for extended periods (e.g., weekend trips)
|
||||||
|
* - Longer than typical prefetch windows (5 minutes before notification)
|
||||||
|
*
|
||||||
|
* **Security Considerations:**
|
||||||
|
* - Tokens are used only for read-only prefetch operations
|
||||||
|
* - Tokens are stored securely in plugin's Room database
|
||||||
|
* - Tokens are refreshed proactively to minimize exposure window
|
||||||
|
* - No private keys are exposed to native code
|
||||||
|
*
|
||||||
|
* @param {string} did - User DID (Decentralized Identifier) for token issuer
|
||||||
|
* @param {number} expirationMinutes - Optional expiration in minutes (defaults to 72 hours = 4320 minutes)
|
||||||
|
* @return {Promise<string>} JWT token with extended validity, or empty string if no DID provided
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Generate token with default 72-hour expiration
|
||||||
|
* const token = await accessTokenForBackground("did:ethr:0x...");
|
||||||
|
*
|
||||||
|
* // Generate token with custom expiration (24 hours)
|
||||||
|
* const token24h = await accessTokenForBackground("did:ethr:0x...", 24 * 60);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link accessToken} For short-lived tokens (1 minute) for regular API requests
|
||||||
|
* @see {@link createEndorserJwtForDid} For JWT creation implementation
|
||||||
|
*/
|
||||||
|
export const accessTokenForBackground = async (
|
||||||
|
did?: string,
|
||||||
|
expirationMinutes?: number,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!did) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use provided expiration or default to 72 hours (4320 minutes)
|
||||||
|
// This allows background prefetch operations to work offline for extended periods
|
||||||
|
const expirationSeconds = expirationMinutes
|
||||||
|
? expirationMinutes * 60
|
||||||
|
: 72 * 60 * 60; // Default 72 hours
|
||||||
|
|
||||||
|
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||||
|
const endEpoch = nowEpoch + expirationSeconds;
|
||||||
|
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||||
|
return createEndorserJwtForDid(did, tokenPayload);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract JWT from various URL formats
|
* Extract JWT from various URL formats
|
||||||
* @param jwtUrlText The URL containing the JWT
|
* @param jwtUrlText The URL containing the JWT
|
||||||
|
|||||||
@@ -1686,7 +1686,10 @@ export async function register(
|
|||||||
"Registration thrown error:",
|
"Registration thrown error:",
|
||||||
errorMessage || JSON.stringify(err),
|
errorMessage || JSON.stringify(err),
|
||||||
);
|
);
|
||||||
return { error: errorMessage || "Got a server error when registering." };
|
return {
|
||||||
|
error:
|
||||||
|
(errorMessage as string) || "Got a server error when registering.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { error: "Got a server error when registering." };
|
return { error: "Got a server error when registering." };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,68 @@ export interface PlatformCapabilities {
|
|||||||
isNativeApp: boolean;
|
isNativeApp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission status for notifications
|
||||||
|
*/
|
||||||
|
export interface PermissionStatus {
|
||||||
|
/** Notification permission status */
|
||||||
|
notifications: "granted" | "denied" | "prompt";
|
||||||
|
/** Exact alarms permission status (Android only) */
|
||||||
|
exactAlarms?: "granted" | "denied" | "prompt";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of permission request
|
||||||
|
*/
|
||||||
|
export interface PermissionResult {
|
||||||
|
/** Whether notification permission was granted */
|
||||||
|
notifications: boolean;
|
||||||
|
/** Whether exact alarms permission was granted (Android only) */
|
||||||
|
exactAlarms?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of scheduled daily notifications
|
||||||
|
*/
|
||||||
|
export interface NotificationStatus {
|
||||||
|
/** Whether a notification is currently scheduled */
|
||||||
|
isScheduled: boolean;
|
||||||
|
/** Scheduled time in "HH:mm" format (24-hour) */
|
||||||
|
scheduledTime?: string;
|
||||||
|
/** Last time the notification was triggered (ISO string) */
|
||||||
|
lastTriggered?: string;
|
||||||
|
/** Current permission status */
|
||||||
|
permissions: PermissionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for scheduling a daily notification
|
||||||
|
*/
|
||||||
|
export interface ScheduleOptions {
|
||||||
|
/** Time in "HH:mm" format (24-hour) in local time */
|
||||||
|
time: string;
|
||||||
|
/** Notification title */
|
||||||
|
title: string;
|
||||||
|
/** Notification body text */
|
||||||
|
body: string;
|
||||||
|
/** Whether to play sound (default: true) */
|
||||||
|
sound?: boolean;
|
||||||
|
/** Notification priority */
|
||||||
|
priority?: "high" | "normal" | "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for native fetcher background operations
|
||||||
|
*/
|
||||||
|
export interface NativeFetcherConfig {
|
||||||
|
/** API server URL */
|
||||||
|
apiServer: string;
|
||||||
|
/** JWT token for authentication */
|
||||||
|
jwt: string;
|
||||||
|
/** Array of starred plan handle IDs */
|
||||||
|
starredPlanHandleIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platform-agnostic interface for handling platform-specific operations.
|
* Platform-agnostic interface for handling platform-specific operations.
|
||||||
* Provides a common API for file system operations, camera interactions,
|
* Provides a common API for file system operations, camera interactions,
|
||||||
@@ -209,6 +271,58 @@ export interface PlatformService {
|
|||||||
*/
|
*/
|
||||||
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
|
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
|
||||||
|
|
||||||
|
// Daily notification operations
|
||||||
|
/**
|
||||||
|
* Get the status of scheduled daily notifications
|
||||||
|
* @returns Promise resolving to notification status, or null if not supported
|
||||||
|
*/
|
||||||
|
getDailyNotificationStatus(): Promise<NotificationStatus | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check notification permissions
|
||||||
|
* @returns Promise resolving to permission status, or null if not supported
|
||||||
|
*/
|
||||||
|
checkNotificationPermissions(): Promise<PermissionStatus | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request notification permissions
|
||||||
|
* @returns Promise resolving to permission result, or null if not supported
|
||||||
|
*/
|
||||||
|
requestNotificationPermissions(): Promise<PermissionResult | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a daily notification
|
||||||
|
* @param options - Notification scheduling options
|
||||||
|
* @returns Promise that resolves when scheduled, or rejects if not supported
|
||||||
|
*/
|
||||||
|
scheduleDailyNotification(options: ScheduleOptions): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel scheduled daily notification
|
||||||
|
* @returns Promise that resolves when cancelled, or rejects if not supported
|
||||||
|
*/
|
||||||
|
cancelDailyNotification(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure native fetcher for background operations
|
||||||
|
* @param config - Native fetcher configuration
|
||||||
|
* @returns Promise that resolves when configured, or null if not supported
|
||||||
|
*/
|
||||||
|
configureNativeFetcher(config: NativeFetcherConfig): Promise<void | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update starred plans for background fetcher
|
||||||
|
* @param plans - Starred plan IDs
|
||||||
|
* @returns Promise that resolves when updated, or null if not supported
|
||||||
|
*/
|
||||||
|
updateStarredPlans(plans: { planIds: string[] }): Promise<void | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app's notification settings in the system settings
|
||||||
|
* @returns Promise that resolves when the settings page is opened, or null if not supported
|
||||||
|
*/
|
||||||
|
openAppNotificationSettings(): Promise<void | null>;
|
||||||
|
|
||||||
// --- PWA/Web-only methods (optional, only implemented on web) ---
|
// --- PWA/Web-only methods (optional, only implemented on web) ---
|
||||||
/**
|
/**
|
||||||
* Registers the service worker for PWA support (web only)
|
* Registers the service worker for PWA support (web only)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
CapacitorSQLite,
|
CapacitorSQLite,
|
||||||
DBSQLiteValues,
|
DBSQLiteValues,
|
||||||
} from "@capacitor-community/sqlite";
|
} from "@capacitor-community/sqlite";
|
||||||
|
import { DailyNotification } from "@timesafari/daily-notification-plugin";
|
||||||
|
|
||||||
import { runMigrations } from "@/db-sql/migration";
|
import { runMigrations } from "@/db-sql/migration";
|
||||||
import { QueryExecResult } from "@/interfaces/database";
|
import { QueryExecResult } from "@/interfaces/database";
|
||||||
@@ -20,6 +21,11 @@ import {
|
|||||||
ImageResult,
|
ImageResult,
|
||||||
PlatformService,
|
PlatformService,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
|
NotificationStatus,
|
||||||
|
PermissionStatus,
|
||||||
|
PermissionResult,
|
||||||
|
ScheduleOptions,
|
||||||
|
NativeFetcherConfig,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { BaseDatabaseService } from "./BaseDatabaseService";
|
import { BaseDatabaseService } from "./BaseDatabaseService";
|
||||||
@@ -91,16 +97,92 @@ export class CapacitorPlatformService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create/Open database
|
// Try to create/Open database connection
|
||||||
this.db = await this.sqlite.createConnection(
|
try {
|
||||||
this.dbName,
|
this.db = await this.sqlite.createConnection(
|
||||||
false,
|
this.dbName,
|
||||||
"no-encryption",
|
false,
|
||||||
1,
|
"no-encryption",
|
||||||
false,
|
1,
|
||||||
);
|
false,
|
||||||
|
);
|
||||||
|
} catch (createError: unknown) {
|
||||||
|
// If connection already exists, try to retrieve it or handle gracefully
|
||||||
|
const errorMessage =
|
||||||
|
createError instanceof Error
|
||||||
|
? createError.message
|
||||||
|
: String(createError);
|
||||||
|
const errorObj =
|
||||||
|
typeof createError === "object" && createError !== null
|
||||||
|
? (createError as { errorMessage?: string; message?: string })
|
||||||
|
: {};
|
||||||
|
|
||||||
await this.db.open();
|
const fullErrorMessage =
|
||||||
|
errorObj.errorMessage || errorObj.message || errorMessage;
|
||||||
|
|
||||||
|
if (fullErrorMessage.includes("already exists")) {
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
|
||||||
|
);
|
||||||
|
// Check if connection exists in JavaScript Map
|
||||||
|
const isConnResult = await this.sqlite.isConnection(
|
||||||
|
this.dbName,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (isConnResult.result) {
|
||||||
|
// Connection exists in Map, retrieve it
|
||||||
|
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Connection exists on native side but not in JavaScript Map
|
||||||
|
// This can happen when the app is restarted but native connections persist
|
||||||
|
// Try to close the native connection first, then create a new one
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await this.sqlite.closeConnection(this.dbName, false);
|
||||||
|
} catch (closeError) {
|
||||||
|
// Ignore close errors - connection might not be properly tracked
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Error closing connection (may be expected):",
|
||||||
|
closeError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Now try to create the connection again
|
||||||
|
this.db = await this.sqlite.createConnection(
|
||||||
|
this.dbName,
|
||||||
|
false,
|
||||||
|
"no-encryption",
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Successfully created connection after cleanup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Re-throw if it's a different error
|
||||||
|
throw createError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the connection if it's not already open
|
||||||
|
try {
|
||||||
|
await this.db.open();
|
||||||
|
} catch (openError: unknown) {
|
||||||
|
const openErrorMessage =
|
||||||
|
openError instanceof Error ? openError.message : String(openError);
|
||||||
|
// If already open, that's fine - continue
|
||||||
|
if (!openErrorMessage.includes("already open")) {
|
||||||
|
throw openError;
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Database connection already open",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Set journal mode to WAL for better performance
|
// Set journal mode to WAL for better performance
|
||||||
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
||||||
@@ -1333,6 +1415,460 @@ export class CapacitorPlatformService
|
|||||||
// --- PWA/Web-only methods (no-op for Capacitor) ---
|
// --- PWA/Web-only methods (no-op for Capacitor) ---
|
||||||
public registerServiceWorker(): void {}
|
public registerServiceWorker(): void {}
|
||||||
|
|
||||||
|
// Daily notification operations
|
||||||
|
/**
|
||||||
|
* Get the status of scheduled daily notifications
|
||||||
|
* @see PlatformService.getDailyNotificationStatus
|
||||||
|
*/
|
||||||
|
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
|
||||||
|
try {
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Getting daily notification status...",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pluginStatus = await DailyNotification.getNotificationStatus();
|
||||||
|
|
||||||
|
// Get permissions separately
|
||||||
|
const permissions = await DailyNotification.checkPermissions();
|
||||||
|
|
||||||
|
// Map plugin PermissionState to our PermissionStatus format
|
||||||
|
const notificationsPermission = permissions.notifications;
|
||||||
|
let notifications: "granted" | "denied" | "prompt";
|
||||||
|
|
||||||
|
if (notificationsPermission === "granted") {
|
||||||
|
notifications = "granted";
|
||||||
|
} else if (notificationsPermission === "denied") {
|
||||||
|
notifications = "denied";
|
||||||
|
} else {
|
||||||
|
notifications = "prompt";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle lastNotificationTime which can be a Promise<number>
|
||||||
|
let lastTriggered: string | undefined;
|
||||||
|
const lastNotificationTime = pluginStatus.lastNotificationTime;
|
||||||
|
if (lastNotificationTime) {
|
||||||
|
const timeValue = await Promise.resolve(lastNotificationTime);
|
||||||
|
if (typeof timeValue === "number") {
|
||||||
|
lastTriggered = new Date(timeValue).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isScheduled: pluginStatus.isScheduled ?? false,
|
||||||
|
scheduledTime: pluginStatus.settings?.time,
|
||||||
|
lastTriggered,
|
||||||
|
permissions: {
|
||||||
|
notifications,
|
||||||
|
exactAlarms: undefined, // Plugin doesn't expose this in status
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to get notification status:",
|
||||||
|
errorMessage,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
logger.warn(
|
||||||
|
"[CapacitorPlatformService] Daily notification section will be hidden - plugin may not be installed or available",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check notification permissions
|
||||||
|
* @see PlatformService.checkNotificationPermissions
|
||||||
|
*/
|
||||||
|
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
|
||||||
|
try {
|
||||||
|
const permissions = await DailyNotification.checkPermissions();
|
||||||
|
|
||||||
|
// Log the raw permission state for debugging
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Raw permission state from plugin:`,
|
||||||
|
permissions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map plugin PermissionState to our PermissionStatus format
|
||||||
|
const notificationsPermission = permissions.notifications;
|
||||||
|
let notifications: "granted" | "denied" | "prompt";
|
||||||
|
|
||||||
|
// Handle all possible PermissionState values
|
||||||
|
if (notificationsPermission === "granted") {
|
||||||
|
notifications = "granted";
|
||||||
|
} else if (
|
||||||
|
notificationsPermission === "denied" ||
|
||||||
|
notificationsPermission === "ephemeral"
|
||||||
|
) {
|
||||||
|
notifications = "denied";
|
||||||
|
} else {
|
||||||
|
// Treat "prompt", "prompt-with-rationale", "unknown", "provisional" as "prompt"
|
||||||
|
// This allows Android to show the permission dialog
|
||||||
|
notifications = "prompt";
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Mapped permission state: ${notifications} (from ${notificationsPermission})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
exactAlarms: undefined, // Plugin doesn't expose this directly
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to check permissions:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request notification permissions
|
||||||
|
* @see PlatformService.requestNotificationPermissions
|
||||||
|
*/
|
||||||
|
async requestNotificationPermissions(): Promise<PermissionResult | null> {
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Requesting notification permissions...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await DailyNotification.requestPermissions();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Permission request result:`,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map plugin PermissionState to boolean
|
||||||
|
const notificationsGranted = result.notifications === "granted";
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Mapped permission result: ${notificationsGranted} (from ${result.notifications})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: notificationsGranted,
|
||||||
|
exactAlarms: undefined, // Plugin doesn't expose this directly
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to request permissions:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a daily notification
|
||||||
|
* @see PlatformService.scheduleDailyNotification
|
||||||
|
*/
|
||||||
|
async scheduleDailyNotification(options: ScheduleOptions): Promise<void> {
|
||||||
|
try {
|
||||||
|
await DailyNotification.scheduleDailyNotification({
|
||||||
|
time: options.time,
|
||||||
|
title: options.title,
|
||||||
|
body: options.body,
|
||||||
|
sound: options.sound ?? true,
|
||||||
|
priority: options.priority ?? "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Scheduled daily notification for ${options.time}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to schedule notification:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel scheduled daily notification
|
||||||
|
* @see PlatformService.cancelDailyNotification
|
||||||
|
*/
|
||||||
|
async cancelDailyNotification(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await DailyNotification.cancelAllNotifications();
|
||||||
|
|
||||||
|
logger.info("[CapacitorPlatformService] Cancelled daily notification");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to cancel notification:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure native fetcher for background operations
|
||||||
|
*
|
||||||
|
* This method configures the daily notification plugin's native content fetcher
|
||||||
|
* with authentication credentials for background prefetch operations. It automatically
|
||||||
|
* retrieves the active DID from the database and generates a fresh JWT token with
|
||||||
|
* 72-hour expiration.
|
||||||
|
*
|
||||||
|
* **Authentication Flow:**
|
||||||
|
* 1. Retrieves active DID from `active_identity` table (single source of truth)
|
||||||
|
* 2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()`
|
||||||
|
* 3. Configures plugin with API server URL, active DID, and JWT token
|
||||||
|
* 4. Plugin stores token in its Room database for background workers
|
||||||
|
*
|
||||||
|
* **Token Management:**
|
||||||
|
* - Tokens are valid for 72 hours (4320 minutes)
|
||||||
|
* - Tokens are refreshed proactively when app comes to foreground
|
||||||
|
* - If token expires while offline, plugin uses cached content
|
||||||
|
* - Token refresh happens automatically via `DailyNotificationSection.refreshNativeFetcherConfig()`
|
||||||
|
*
|
||||||
|
* **Offline-First Design:**
|
||||||
|
* - 72-hour validity supports extended offline periods
|
||||||
|
* - Plugin can prefetch content when online and use cached content when offline
|
||||||
|
* - No app wake-up required for token refresh (happens when app is already open)
|
||||||
|
*
|
||||||
|
* **Error Handling:**
|
||||||
|
* - Returns `null` if active DID not found (no user logged in)
|
||||||
|
* - Returns `null` if JWT generation fails
|
||||||
|
* - Logs errors but doesn't throw (allows graceful degradation)
|
||||||
|
*
|
||||||
|
* @param config - Native fetcher configuration
|
||||||
|
* @param config.apiServer - API server URL (optional, uses default if not provided)
|
||||||
|
* @param config.jwt - JWT token (ignored, generated automatically)
|
||||||
|
* @param config.starredPlanHandleIds - Array of starred plan handle IDs for prefetch
|
||||||
|
* @returns Promise that resolves when configured, or `null` if configuration failed
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* await platformService.configureNativeFetcher({
|
||||||
|
* apiServer: "https://api.endorser.ch",
|
||||||
|
* jwt: "", // Generated automatically
|
||||||
|
* starredPlanHandleIds: ["plan-123", "plan-456"]
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link accessTokenForBackground} For JWT token generation
|
||||||
|
* @see {@link DailyNotificationSection.refreshNativeFetcherConfig} For proactive token refresh
|
||||||
|
* @see PlatformService.configureNativeFetcher
|
||||||
|
*/
|
||||||
|
async configureNativeFetcher(
|
||||||
|
config: NativeFetcherConfig,
|
||||||
|
): Promise<void | null> {
|
||||||
|
try {
|
||||||
|
// Step 1: Get activeDid from database (single source of truth)
|
||||||
|
// This ensures we're using the correct user identity for authentication
|
||||||
|
const activeIdentity = await this.getActiveIdentity();
|
||||||
|
const activeDid = activeIdentity.activeDid;
|
||||||
|
|
||||||
|
if (!activeDid) {
|
||||||
|
logger.warn(
|
||||||
|
"[CapacitorPlatformService] No activeDid found, cannot configure native fetcher",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Generate JWT token for background operations
|
||||||
|
// Use 72-hour expiration for offline-first prefetch operations
|
||||||
|
// This allows the plugin to work offline for extended periods
|
||||||
|
const { accessTokenForBackground } = await import(
|
||||||
|
"../../libs/crypto/index"
|
||||||
|
);
|
||||||
|
// Use 72 hours (4320 minutes) for background prefetch tokens
|
||||||
|
// This is longer than passkey expiration to support offline scenarios
|
||||||
|
const expirationMinutes = 72 * 60; // 72 hours
|
||||||
|
const jwtToken = await accessTokenForBackground(
|
||||||
|
activeDid,
|
||||||
|
expirationMinutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!jwtToken) {
|
||||||
|
logger.error("[CapacitorPlatformService] Failed to generate JWT token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get API server from config or use default
|
||||||
|
// This ensures the plugin knows where to fetch content from
|
||||||
|
let apiServer =
|
||||||
|
config.apiServer ||
|
||||||
|
(await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER;
|
||||||
|
|
||||||
|
// Step 3.5: Convert localhost to 10.0.2.2 for Android emulators
|
||||||
|
// Android emulators can't reach localhost - they need 10.0.2.2 to access the host machine
|
||||||
|
const platform = Capacitor.getPlatform();
|
||||||
|
if (platform === "android" && apiServer) {
|
||||||
|
// Replace localhost or 127.0.0.1 with 10.0.2.2 for Android emulator compatibility
|
||||||
|
apiServer = apiServer.replace(
|
||||||
|
/http:\/\/(localhost|127\.0\.0\.1)(:\d+)?/,
|
||||||
|
"http://10.0.2.2$2",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Configure plugin with credentials
|
||||||
|
// Plugin stores these in its Room database for background workers
|
||||||
|
await DailyNotification.configureNativeFetcher({
|
||||||
|
apiBaseUrl: apiServer,
|
||||||
|
activeDid,
|
||||||
|
jwtToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Update starred plans if provided
|
||||||
|
// This stores the starred plan IDs in SharedPreferences for the native fetcher
|
||||||
|
if (
|
||||||
|
config.starredPlanHandleIds &&
|
||||||
|
config.starredPlanHandleIds.length > 0
|
||||||
|
) {
|
||||||
|
await DailyNotification.updateStarredPlans({
|
||||||
|
planIds: config.starredPlanHandleIds,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Updated starred plans: ${config.starredPlanHandleIds.length} plans`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Clear starred plans if none provided
|
||||||
|
await DailyNotification.updateStarredPlans({
|
||||||
|
planIds: [],
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
"[CapacitorPlatformService] Cleared starred plans (none provided)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("[CapacitorPlatformService] Configured native fetcher", {
|
||||||
|
activeDid,
|
||||||
|
apiServer,
|
||||||
|
tokenExpirationHours: 72,
|
||||||
|
tokenExpirationMinutes: expirationMinutes,
|
||||||
|
starredPlansCount: config.starredPlanHandleIds?.length || 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to configure native fetcher:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update starred plans for background fetcher
|
||||||
|
* @see PlatformService.updateStarredPlans
|
||||||
|
*/
|
||||||
|
async updateStarredPlans(plans: { planIds: string[] }): Promise<void | null> {
|
||||||
|
try {
|
||||||
|
await DailyNotification.updateStarredPlans({
|
||||||
|
planIds: plans.planIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Updated starred plans: ${plans.planIds.length} plans`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to update starred plans:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app's notification settings in the system settings
|
||||||
|
* @see PlatformService.openAppNotificationSettings
|
||||||
|
*/
|
||||||
|
async openAppNotificationSettings(): Promise<void | null> {
|
||||||
|
try {
|
||||||
|
const platform = Capacitor.getPlatform();
|
||||||
|
|
||||||
|
if (platform === "android") {
|
||||||
|
// Android: Open app details settings page
|
||||||
|
// From there, users can navigate to "Notifications" section
|
||||||
|
// This is more reliable than trying to open notification settings directly
|
||||||
|
const packageName = "app.timesafari.app"; // Full application ID from build.gradle
|
||||||
|
|
||||||
|
// Use APPLICATION_DETAILS_SETTINGS which opens the app's settings page
|
||||||
|
// Users can then navigate to "Notifications" section
|
||||||
|
// Try multiple URL formats to ensure compatibility
|
||||||
|
const intentUrl1 = `intent:#Intent;action=android.settings.APPLICATION_DETAILS_SETTINGS;data=package:${packageName};end`;
|
||||||
|
const intentUrl2 = `intent://settings/app_detail?package=${packageName}#Intent;scheme=android-app;end`;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Opening Android app settings for ${packageName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log current permission state before opening settings
|
||||||
|
try {
|
||||||
|
const currentPerms = await this.checkNotificationPermissions();
|
||||||
|
logger.info(
|
||||||
|
`[CapacitorPlatformService] Current permission state before opening settings:`,
|
||||||
|
currentPerms,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(
|
||||||
|
`[CapacitorPlatformService] Could not check permissions before opening settings:`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try multiple approaches to ensure it works
|
||||||
|
try {
|
||||||
|
// Method 1: Direct window.location.href (most reliable)
|
||||||
|
window.location.href = intentUrl1;
|
||||||
|
|
||||||
|
// Method 2: Fallback with window.open
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
window.open(intentUrl1, "_blank");
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(
|
||||||
|
"[CapacitorPlatformService] window.open fallback failed:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Method 3: Alternative format
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
window.location.href = intentUrl2;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(
|
||||||
|
"[CapacitorPlatformService] Alternative format failed:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to open intent URL:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (platform === "ios") {
|
||||||
|
// iOS: Use app settings URL scheme
|
||||||
|
const settingsUrl = `app-settings:`;
|
||||||
|
window.location.href = settingsUrl;
|
||||||
|
|
||||||
|
logger.info("[CapacitorPlatformService] Opening iOS app settings");
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`[CapacitorPlatformService] Cannot open settings on platform: ${platform}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[CapacitorPlatformService] Failed to open app notification settings:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Database utility methods - inherited from BaseDatabaseService
|
// Database utility methods - inherited from BaseDatabaseService
|
||||||
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
||||||
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
|
|
||||||
import { CapacitorPlatformService } from "./CapacitorPlatformService";
|
import { CapacitorPlatformService } from "./CapacitorPlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
|
import {
|
||||||
|
NotificationStatus,
|
||||||
|
PermissionStatus,
|
||||||
|
PermissionResult,
|
||||||
|
ScheduleOptions,
|
||||||
|
NativeFetcherConfig,
|
||||||
|
} from "../PlatformService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Electron-specific platform service implementation.
|
* Electron-specific platform service implementation.
|
||||||
@@ -166,4 +173,88 @@ export class ElectronPlatformService extends CapacitorPlatformService {
|
|||||||
|
|
||||||
// --- PWA/Web-only methods (no-op for Electron) ---
|
// --- PWA/Web-only methods (no-op for Electron) ---
|
||||||
public registerServiceWorker(): void {}
|
public registerServiceWorker(): void {}
|
||||||
|
|
||||||
|
// Daily notification operations
|
||||||
|
// Override CapacitorPlatformService methods to return null/throw errors
|
||||||
|
// since Electron doesn't support native daily notifications
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status of scheduled daily notifications
|
||||||
|
* @see PlatformService.getDailyNotificationStatus
|
||||||
|
* @returns null - notifications not supported on Electron platform
|
||||||
|
*/
|
||||||
|
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check notification permissions
|
||||||
|
* @see PlatformService.checkNotificationPermissions
|
||||||
|
* @returns null - notifications not supported on Electron platform
|
||||||
|
*/
|
||||||
|
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request notification permissions
|
||||||
|
* @see PlatformService.requestNotificationPermissions
|
||||||
|
* @returns null - notifications not supported on Electron platform
|
||||||
|
*/
|
||||||
|
async requestNotificationPermissions(): Promise<PermissionResult | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a daily notification
|
||||||
|
* @see PlatformService.scheduleDailyNotification
|
||||||
|
* @throws Error - notifications not supported on Electron platform
|
||||||
|
*/
|
||||||
|
async scheduleDailyNotification(_options: ScheduleOptions): Promise<void> {
|
||||||
|
throw new Error(
|
||||||
|
"Daily notifications are not supported on Electron platform",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel scheduled daily notification
|
||||||
|
* @see PlatformService.cancelDailyNotification
|
||||||
|
* @throws Error - notifications not supported on Electron platform
|
||||||
|
*/
|
||||||
|
async cancelDailyNotification(): Promise<void> {
|
||||||
|
throw new Error(
|
||||||
|
"Daily notifications are not supported on Electron platform",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure native fetcher for background operations
|
||||||
|
* @see PlatformService.configureNativeFetcher
|
||||||
|
* @returns null - native fetcher not supported on Electron platform
|
||||||
|
*/
|
||||||
|
async configureNativeFetcher(
|
||||||
|
_config: NativeFetcherConfig,
|
||||||
|
): Promise<void | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update starred plans for background fetcher
|
||||||
|
* @see PlatformService.updateStarredPlans
|
||||||
|
* @returns null - native fetcher not supported on Electron platform
|
||||||
|
*/
|
||||||
|
async updateStarredPlans(_plans: {
|
||||||
|
planIds: string[];
|
||||||
|
}): Promise<void | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app's notification settings in the system settings
|
||||||
|
* @see PlatformService.openAppNotificationSettings
|
||||||
|
* @returns null - not supported on Electron platform
|
||||||
|
*/
|
||||||
|
async openAppNotificationSettings(): Promise<void | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import {
|
|||||||
ImageResult,
|
ImageResult,
|
||||||
PlatformService,
|
PlatformService,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
|
NotificationStatus,
|
||||||
|
PermissionStatus,
|
||||||
|
PermissionResult,
|
||||||
|
ScheduleOptions,
|
||||||
|
NativeFetcherConfig,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { QueryExecResult } from "@/interfaces/database";
|
import { QueryExecResult } from "@/interfaces/database";
|
||||||
@@ -677,4 +682,81 @@ export class WebPlatformService
|
|||||||
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
|
||||||
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
|
||||||
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
|
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
|
||||||
|
|
||||||
|
// Daily notification operations
|
||||||
|
/**
|
||||||
|
* Get the status of scheduled daily notifications
|
||||||
|
* @see PlatformService.getDailyNotificationStatus
|
||||||
|
* @returns null - notifications not supported on web platform
|
||||||
|
*/
|
||||||
|
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check notification permissions
|
||||||
|
* @see PlatformService.checkNotificationPermissions
|
||||||
|
* @returns null - notifications not supported on web platform
|
||||||
|
*/
|
||||||
|
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request notification permissions
|
||||||
|
* @see PlatformService.requestNotificationPermissions
|
||||||
|
* @returns null - notifications not supported on web platform
|
||||||
|
*/
|
||||||
|
async requestNotificationPermissions(): Promise<PermissionResult | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a daily notification
|
||||||
|
* @see PlatformService.scheduleDailyNotification
|
||||||
|
* @throws Error - notifications not supported on web platform
|
||||||
|
*/
|
||||||
|
async scheduleDailyNotification(_options: ScheduleOptions): Promise<void> {
|
||||||
|
throw new Error("Daily notifications are not supported on web platform");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel scheduled daily notification
|
||||||
|
* @see PlatformService.cancelDailyNotification
|
||||||
|
* @throws Error - notifications not supported on web platform
|
||||||
|
*/
|
||||||
|
async cancelDailyNotification(): Promise<void> {
|
||||||
|
throw new Error("Daily notifications are not supported on web platform");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure native fetcher for background operations
|
||||||
|
* @see PlatformService.configureNativeFetcher
|
||||||
|
* @returns null - native fetcher not supported on web platform
|
||||||
|
*/
|
||||||
|
async configureNativeFetcher(
|
||||||
|
_config: NativeFetcherConfig,
|
||||||
|
): Promise<void | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update starred plans for background fetcher
|
||||||
|
* @see PlatformService.updateStarredPlans
|
||||||
|
* @returns null - native fetcher not supported on web platform
|
||||||
|
*/
|
||||||
|
async updateStarredPlans(_plans: {
|
||||||
|
planIds: string[];
|
||||||
|
}): Promise<void | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app's notification settings in the system settings
|
||||||
|
* @see PlatformService.openAppNotificationSettings
|
||||||
|
* @returns null - not supported on web platform
|
||||||
|
*/
|
||||||
|
async openAppNotificationSettings(): Promise<void | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,9 @@
|
|||||||
</section>
|
</section>
|
||||||
<PushNotificationPermission ref="pushNotificationPermission" />
|
<PushNotificationPermission ref="pushNotificationPermission" />
|
||||||
|
|
||||||
|
<!-- Daily Notifications (Native) -->
|
||||||
|
<DailyNotificationSection />
|
||||||
|
|
||||||
<!-- User Profile -->
|
<!-- User Profile -->
|
||||||
<section
|
<section
|
||||||
v-if="isRegistered"
|
v-if="isRegistered"
|
||||||
@@ -790,6 +793,7 @@ import IdentitySection from "@/components/IdentitySection.vue";
|
|||||||
import RegistrationNotice from "@/components/RegistrationNotice.vue";
|
import RegistrationNotice from "@/components/RegistrationNotice.vue";
|
||||||
import LocationSearchSection from "@/components/LocationSearchSection.vue";
|
import LocationSearchSection from "@/components/LocationSearchSection.vue";
|
||||||
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
|
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
|
||||||
|
import DailyNotificationSection from "@/components/notifications/DailyNotificationSection.vue";
|
||||||
import {
|
import {
|
||||||
AppString,
|
AppString,
|
||||||
DEFAULT_IMAGE_API_SERVER,
|
DEFAULT_IMAGE_API_SERVER,
|
||||||
@@ -821,6 +825,7 @@ import {
|
|||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
|
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||||
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||||||
import {
|
import {
|
||||||
@@ -858,6 +863,7 @@ interface UserNameDialogRef {
|
|||||||
RegistrationNotice,
|
RegistrationNotice,
|
||||||
LocationSearchSection,
|
LocationSearchSection,
|
||||||
UsageLimitsSection,
|
UsageLimitsSection,
|
||||||
|
DailyNotificationSection,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
@@ -1542,6 +1548,33 @@ export default class AccountViewView extends Vue {
|
|||||||
settingsSaved: true,
|
settingsSaved: true,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refresh native fetcher configuration with new API server
|
||||||
|
// This ensures background notification prefetch uses the updated endpoint
|
||||||
|
try {
|
||||||
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||||
|
|
||||||
|
await platformService.configureNativeFetcher({
|
||||||
|
apiServer: newApiServer,
|
||||||
|
jwt: "", // Will be generated automatically by configureNativeFetcher
|
||||||
|
starredPlanHandleIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[AccountViewView] Native fetcher configuration refreshed after API server change",
|
||||||
|
{
|
||||||
|
newApiServer,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[AccountViewView] Failed to refresh native fetcher config after API server change:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Don't throw - API server change should still succeed even if native fetcher refresh fails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onClickSavePartnerServer(): Promise<void> {
|
async onClickSavePartnerServer(): Promise<void> {
|
||||||
|
|||||||
@@ -60,60 +60,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||||
|
|
||||||
<!-- Authorized Representative Selection -->
|
<input
|
||||||
<div class="w-full flex items-stretch my-4">
|
v-model="agentDid"
|
||||||
<div
|
type="text"
|
||||||
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"
|
placeholder="Other Authorized Representative"
|
||||||
@click="openRepresentativeDialog"
|
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<EntityIcon
|
|
||||||
v-if="selectedRepresentative"
|
|
||||||
:contact="selectedRepresentative"
|
|
||||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<font-awesome v-else icon="user" class="text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
<div
|
|
||||||
:class="{
|
|
||||||
'text-sm font-semibold': selectedRepresentative,
|
|
||||||
'text-slate-400': !selectedRepresentative,
|
|
||||||
}"
|
|
||||||
class="truncate"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
selectedRepresentative
|
|
||||||
? selectedRepresentative.name || AppString.NO_CONTACT_NAME
|
|
||||||
: "Assign Authorized Representative…"
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="selectedRepresentative"
|
|
||||||
class="text-xs text-slate-500 truncate"
|
|
||||||
>
|
|
||||||
{{ agentDid }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="selectedRepresentative"
|
|
||||||
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
|
||||||
@click="unsetRepresentative"
|
|
||||||
>
|
|
||||||
<font-awesome icon="trash-can" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProjectRepresentativeDialog
|
|
||||||
ref="representativeDialog"
|
|
||||||
:all-contacts="allContacts"
|
|
||||||
:active-did="activeDid"
|
|
||||||
:all-my-dids="allMyDids"
|
|
||||||
:notify="$notify"
|
|
||||||
@assign="handleRepresentativeAssigned"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p v-if="shouldShowOwnershipWarning">
|
<p v-if="shouldShowOwnershipWarning">
|
||||||
<span class="text-red-500">Beware!</span>
|
<span class="text-red-500">Beware!</span>
|
||||||
@@ -280,12 +232,9 @@ import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
|||||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
import { LeafletMouseEvent } from "leaflet";
|
import { LeafletMouseEvent } from "leaflet";
|
||||||
|
|
||||||
import EntityIcon from "../components/EntityIcon.vue";
|
|
||||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||||
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
|
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import {
|
import {
|
||||||
AppString,
|
|
||||||
DEFAULT_IMAGE_API_SERVER,
|
DEFAULT_IMAGE_API_SERVER,
|
||||||
DEFAULT_PARTNER_API_SERVER,
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
@@ -319,7 +268,6 @@ import {
|
|||||||
retrieveAccountCount,
|
retrieveAccountCount,
|
||||||
retrieveFullyDecryptedAccount,
|
retrieveFullyDecryptedAccount,
|
||||||
} from "../libs/util";
|
} from "../libs/util";
|
||||||
import { Contact } from "../db/tables/contacts";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EventTemplate,
|
EventTemplate,
|
||||||
@@ -375,15 +323,7 @@ import { logger } from "../utils/logger";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
|
||||||
EntityIcon,
|
|
||||||
ImageMethodDialog,
|
|
||||||
ProjectRepresentativeDialog,
|
|
||||||
LMap,
|
|
||||||
LMarker,
|
|
||||||
LTileLayer,
|
|
||||||
QuickNav,
|
|
||||||
},
|
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class NewEditProjectView extends Vue {
|
export default class NewEditProjectView extends Vue {
|
||||||
@@ -394,9 +334,6 @@ export default class NewEditProjectView extends Vue {
|
|||||||
// Notification helpers
|
// Notification helpers
|
||||||
private notify!: ReturnType<typeof createNotifyHelpers>;
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
// Constants
|
|
||||||
AppString = AppString;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display error notification to user
|
* Display error notification to user
|
||||||
* Provides consistent error messaging with 5-second timeout
|
* Provides consistent error messaging with 5-second timeout
|
||||||
@@ -409,8 +346,6 @@ export default class NewEditProjectView extends Vue {
|
|||||||
// Component state properties
|
// Component state properties
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
agentDid = "";
|
agentDid = "";
|
||||||
allContacts: Array<Contact> = [];
|
|
||||||
allMyDids: string[] = [];
|
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
endDateInput?: string;
|
endDateInput?: string;
|
||||||
endTimeInput?: string;
|
endTimeInput?: string;
|
||||||
@@ -457,24 +392,16 @@ export default class NewEditProjectView extends Vue {
|
|||||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
|
||||||
// Get all user's DIDs
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
this.allMyDids = await (this as any).$getAllAccountDids();
|
|
||||||
|
|
||||||
// Load contacts sorted by date added (newest first) for consistent "Recently Added" display
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
this.allContacts = await (this as any).$contactsByDateAdded();
|
|
||||||
|
|
||||||
this.apiServer = settings.apiServer || "";
|
this.apiServer = settings.apiServer || "";
|
||||||
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||||
|
|
||||||
this.projectId = (this.$route.query["projectId"] as string) || "";
|
this.projectId = (this.$route.query["projectId"] as string) || "";
|
||||||
|
|
||||||
if (this.isSavedProject()) {
|
if (this.projectId) {
|
||||||
if (this.numAccounts === 0) {
|
if (this.numAccounts === 0) {
|
||||||
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
|
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
|
||||||
} else {
|
} else {
|
||||||
this.loadProject(this.activeDid, this.projectId);
|
this.loadProject(this.activeDid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,9 +411,11 @@ export default class NewEditProjectView extends Vue {
|
|||||||
* Retrieves project information from the API and populates form fields
|
* Retrieves project information from the API and populates form fields
|
||||||
* @param userDid - User's decentralized identifier
|
* @param userDid - User's decentralized identifier
|
||||||
*/
|
*/
|
||||||
async loadProject(userDid: string, projectId: string) {
|
async loadProject(userDid: string) {
|
||||||
const url =
|
const url =
|
||||||
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
this.apiServer +
|
||||||
|
"/api/claim/byHandle/" +
|
||||||
|
encodeURIComponent(this.projectId);
|
||||||
const headers = await getHeaders(userDid);
|
const headers = await getHeaders(userDid);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -503,12 +432,6 @@ export default class NewEditProjectView extends Vue {
|
|||||||
}
|
}
|
||||||
if (this.fullClaim?.agent?.identifier) {
|
if (this.fullClaim?.agent?.identifier) {
|
||||||
this.agentDid = this.fullClaim.agent.identifier;
|
this.agentDid = this.fullClaim.agent.identifier;
|
||||||
if (this.activeDid !== this.projectIssuerDid) {
|
|
||||||
this.agentDid = this.projectIssuerDid;
|
|
||||||
this.notify.warning(
|
|
||||||
"You were previously the agent, so the agent has been set to the previous owner. You can change it.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (this.fullClaim.startTime) {
|
if (this.fullClaim.startTime) {
|
||||||
const localDateTime = DateTime.fromISO(
|
const localDateTime = DateTime.fromISO(
|
||||||
@@ -613,7 +536,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
private async saveProject() {
|
private async saveProject() {
|
||||||
// Make a claim
|
// Make a claim
|
||||||
const vcClaim: PlanActionClaim = this.fullClaim;
|
const vcClaim: PlanActionClaim = this.fullClaim;
|
||||||
if (this.isSavedProject()) {
|
if (this.projectId) {
|
||||||
vcClaim.lastClaimId = this.lastClaimJwtId;
|
vcClaim.lastClaimId = this.lastClaimJwtId;
|
||||||
}
|
}
|
||||||
if (this.agentDid) {
|
if (this.agentDid) {
|
||||||
@@ -947,10 +870,6 @@ export default class NewEditProjectView extends Vue {
|
|||||||
this.longitude = event.latlng.lng;
|
this.longitude = event.latlng.lng;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSavedProject(): boolean {
|
|
||||||
return !!this.projectId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property for character count display
|
* Computed property for character count display
|
||||||
* Shows current description length and maximum character limit
|
* Shows current description length and maximum character limit
|
||||||
@@ -966,7 +885,6 @@ export default class NewEditProjectView extends Vue {
|
|||||||
*/
|
*/
|
||||||
get shouldShowOwnershipWarning(): boolean {
|
get shouldShowOwnershipWarning(): boolean {
|
||||||
return (
|
return (
|
||||||
this.isSavedProject() &&
|
|
||||||
this.activeDid !== this.projectIssuerDid &&
|
this.activeDid !== this.projectIssuerDid &&
|
||||||
this.agentDid !== this.projectIssuerDid
|
this.agentDid !== this.projectIssuerDid
|
||||||
);
|
);
|
||||||
@@ -1043,37 +961,5 @@ export default class NewEditProjectView extends Vue {
|
|||||||
get shouldShowSpinner(): boolean {
|
get shouldShowSpinner(): boolean {
|
||||||
return !this.isHiddenSpinner;
|
return !this.isHiddenSpinner;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Computed property for selected representative contact
|
|
||||||
* Derives the contact from agentDid by finding it in allContacts
|
|
||||||
*/
|
|
||||||
get selectedRepresentative(): Contact | null {
|
|
||||||
if (!this.agentDid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.allContacts.find((c) => c.did === this.agentDid) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the representative selection dialog
|
|
||||||
*/
|
|
||||||
openRepresentativeDialog(): void {
|
|
||||||
(this.$refs.representativeDialog as ProjectRepresentativeDialog).open();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle representative assignment from dialog
|
|
||||||
*/
|
|
||||||
handleRepresentativeAssigned(contact: Contact): void {
|
|
||||||
this.agentDid = contact.did;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unset the representative and revert to initial state
|
|
||||||
*/
|
|
||||||
unsetRepresentative(): void {
|
|
||||||
this.agentDid = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -186,59 +186,16 @@
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="projectLink"
|
for="projectLink"
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
class="block text-sm font-medium text-gray-700"
|
||||||
>Project Link</label
|
>Project Link</label
|
||||||
>
|
>
|
||||||
<div class="w-full flex items-stretch">
|
<input
|
||||||
<div
|
id="projectLink"
|
||||||
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
|
v-model="newOrUpdatedMeetingInputs.projectLink"
|
||||||
@click="openProjectLinkDialog"
|
type="text"
|
||||||
>
|
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||||
<div>
|
placeholder="Project ID"
|
||||||
<ProjectIcon
|
/>
|
||||||
v-if="selectedProject"
|
|
||||||
:entity-id="selectedProject.handleId"
|
|
||||||
:icon-size="30"
|
|
||||||
:image-url="selectedProject.image"
|
|
||||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<font-awesome
|
|
||||||
v-else
|
|
||||||
icon="folder-open"
|
|
||||||
class="text-slate-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
<div
|
|
||||||
:class="{
|
|
||||||
'text-sm font-semibold': selectedProject,
|
|
||||||
'text-slate-400': !selectedProject,
|
|
||||||
}"
|
|
||||||
class="truncate"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
selectedProject
|
|
||||||
? selectedProject.name || "Unnamed Project"
|
|
||||||
: "Select Project…"
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="selectedProject"
|
|
||||||
class="text-xs text-slate-500 truncate"
|
|
||||||
>
|
|
||||||
<font-awesome icon="user" class="text-slate-400" />
|
|
||||||
{{ selectedProjectIssuerName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="selectedProject"
|
|
||||||
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
|
||||||
@click="unsetProjectLink"
|
|
||||||
>
|
|
||||||
<font-awesome icon="trash-can" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -267,17 +224,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MeetingProjectDialog
|
|
||||||
ref="meetingProjectDialog"
|
|
||||||
:active-did="activeDid"
|
|
||||||
:all-my-dids="allMyDids"
|
|
||||||
:all-contacts="allContacts"
|
|
||||||
:notify="$notify"
|
|
||||||
@assign="handleProjectLinkAssigned"
|
|
||||||
@open="handleDialogOpen"
|
|
||||||
@close="handleDialogClose"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Members Section -->
|
<!-- Members Section -->
|
||||||
<div
|
<div
|
||||||
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
||||||
@@ -308,7 +254,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<MembersList
|
<MembersList
|
||||||
ref="membersList"
|
|
||||||
:password="currentMeeting.password || ''"
|
:password="currentMeeting.password || ''"
|
||||||
:show-organizer-tools="true"
|
:show-organizer-tools="true"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@@ -347,13 +292,10 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import MembersList from "../components/MembersList.vue";
|
import MembersList from "../components/MembersList.vue";
|
||||||
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
|
||||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
|
||||||
import {
|
import {
|
||||||
errorStringForLog,
|
errorStringForLog,
|
||||||
getHeaders,
|
getHeaders,
|
||||||
serverMessageForUser,
|
serverMessageForUser,
|
||||||
didInfo,
|
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import { encryptMessage } from "../libs/crypto";
|
import { encryptMessage } from "../libs/crypto";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
@@ -367,8 +309,6 @@ import {
|
|||||||
NOTIFY_MEETING_DELETED,
|
NOTIFY_MEETING_DELETED,
|
||||||
NOTIFY_MEETING_LINK_COPIED,
|
NOTIFY_MEETING_LINK_COPIED,
|
||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
import { PlanData } from "../interfaces/records";
|
|
||||||
import { Contact } from "../db/tables/contacts";
|
|
||||||
interface ServerMeeting {
|
interface ServerMeeting {
|
||||||
groupId: number; // from the server
|
groupId: number; // from the server
|
||||||
name: string; // to & from the server
|
name: string; // to & from the server
|
||||||
@@ -391,8 +331,6 @@ interface MeetingSetupInputs {
|
|||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
MembersList,
|
MembersList,
|
||||||
MeetingProjectDialog,
|
|
||||||
ProjectIcon,
|
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
@@ -416,9 +354,6 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
showDeleteConfirm = false;
|
showDeleteConfirm = false;
|
||||||
fullName = "";
|
fullName = "";
|
||||||
allContacts: Contact[] = [];
|
|
||||||
allMyDids: string[] = [];
|
|
||||||
selectedProjectData: PlanData | null = null;
|
|
||||||
get minDateTime() {
|
get minDateTime() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
||||||
@@ -435,17 +370,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.fullName = settings?.firstName || "";
|
this.fullName = settings?.firstName || "";
|
||||||
this.isRegistered = !!settings?.isRegistered;
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
|
|
||||||
// Load contacts and DIDs for dialog
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
this.allContacts = await (this as any).$contactsByDateAdded();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
this.allMyDids = await (this as any).$getAllAccountDids();
|
|
||||||
|
|
||||||
await this.fetchCurrentMeeting();
|
await this.fetchCurrentMeeting();
|
||||||
|
|
||||||
// Ensure selected project is loaded if projectLink exists
|
|
||||||
await this.ensureSelectedProjectLoaded();
|
|
||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,54 +442,6 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the selected project is loaded if projectLink exists
|
|
||||||
*/
|
|
||||||
async ensureSelectedProjectLoaded(): Promise<void> {
|
|
||||||
const projectLink =
|
|
||||||
this.currentMeeting?.projectLink ||
|
|
||||||
this.newOrUpdatedMeetingInputs?.projectLink;
|
|
||||||
|
|
||||||
if (!projectLink) {
|
|
||||||
this.selectedProjectData = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.fetchProjectByHandleId(projectLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a single project by handleId
|
|
||||||
* @param handleId - The project handleId to fetch
|
|
||||||
*/
|
|
||||||
async fetchProjectByHandleId(handleId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const headers = await getHeaders(this.activeDid);
|
|
||||||
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
|
|
||||||
const resp = await this.axios.get(url, { headers });
|
|
||||||
|
|
||||||
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
|
|
||||||
const project = resp.data.data[0];
|
|
||||||
this.selectedProjectData = {
|
|
||||||
name: project.name,
|
|
||||||
description: project.description,
|
|
||||||
image: project.image,
|
|
||||||
handleId: project.handleId,
|
|
||||||
issuerDid: project.issuerDid,
|
|
||||||
rowId: project.rowId,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.selectedProjectData = null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.$logAndConsole(
|
|
||||||
"Error fetching project by handleId: " + errorStringForLog(error),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
this.selectedProjectData = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createMeeting() {
|
async createMeeting() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
@@ -699,7 +576,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async startEditing() {
|
startEditing() {
|
||||||
// Populate form with existing meeting data
|
// Populate form with existing meeting data
|
||||||
if (this.currentMeeting) {
|
if (this.currentMeeting) {
|
||||||
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||||
@@ -710,10 +587,6 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
password: this.currentMeeting.password || "",
|
password: this.currentMeeting.password || "",
|
||||||
projectLink: this.currentMeeting.projectLink || "",
|
projectLink: this.currentMeeting.projectLink || "",
|
||||||
};
|
};
|
||||||
// Ensure selected project is loaded if projectLink exists
|
|
||||||
if (this.currentMeeting.projectLink) {
|
|
||||||
await this.ensureSelectedProjectLoaded();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.$logError(
|
this.$logError(
|
||||||
"There is no current meeting to edit. We should never get here.",
|
"There is no current meeting to edit. We should never get here.",
|
||||||
@@ -721,15 +594,9 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelEditing() {
|
cancelEditing() {
|
||||||
// Reset form data
|
// Reset form data
|
||||||
this.newOrUpdatedMeetingInputs = null;
|
this.newOrUpdatedMeetingInputs = null;
|
||||||
// Restore selected project from currentMeeting if it exists
|
|
||||||
if (this.currentMeeting?.projectLink) {
|
|
||||||
await this.ensureSelectedProjectLoaded();
|
|
||||||
} else {
|
|
||||||
this.selectedProjectData = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMeeting() {
|
async updateMeeting() {
|
||||||
@@ -843,78 +710,5 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.notify.error("Failed to copy meeting link to clipboard.");
|
this.notify.error("Failed to copy meeting link to clipboard.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Computed property for selected project
|
|
||||||
* Returns the separately stored selected project data
|
|
||||||
*/
|
|
||||||
get selectedProject(): PlanData | null {
|
|
||||||
return this.selectedProjectData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computed property for selected project issuer display name
|
|
||||||
* Uses didInfo to format the issuer name similar to ProjectCard
|
|
||||||
*/
|
|
||||||
get selectedProjectIssuerName(): string {
|
|
||||||
if (!this.selectedProject) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return didInfo(
|
|
||||||
this.selectedProject.issuerDid,
|
|
||||||
this.activeDid,
|
|
||||||
this.allMyDids,
|
|
||||||
this.allContacts,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the project link selection dialog
|
|
||||||
*/
|
|
||||||
openProjectLinkDialog(): void {
|
|
||||||
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle project assignment from dialog
|
|
||||||
*/
|
|
||||||
handleProjectLinkAssigned(project: PlanData): void {
|
|
||||||
// Store the selected project directly
|
|
||||||
this.selectedProjectData = project;
|
|
||||||
|
|
||||||
if (this.newOrUpdatedMeetingInputs) {
|
|
||||||
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unset the project link and revert to initial state
|
|
||||||
*/
|
|
||||||
unsetProjectLink(): void {
|
|
||||||
this.selectedProjectData = null;
|
|
||||||
if (this.newOrUpdatedMeetingInputs) {
|
|
||||||
this.newOrUpdatedMeetingInputs.projectLink = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle dialog open event - stop auto-refresh in MembersList
|
|
||||||
*/
|
|
||||||
handleDialogOpen(): void {
|
|
||||||
const membersList = this.$refs.membersList as MembersList;
|
|
||||||
if (membersList) {
|
|
||||||
membersList.stopAutoRefresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle dialog close event - start auto-refresh in MembersList
|
|
||||||
*/
|
|
||||||
handleDialogClose(): void {
|
|
||||||
const membersList = this.$refs.membersList as MembersList;
|
|
||||||
if (membersList) {
|
|
||||||
membersList.startAutoRefresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { createBuildConfig } from "./vite.config.common.mts";
|
import { createBuildConfig } from "./vite.config.common.mts";
|
||||||
|
|
||||||
export default defineConfig(async () => createBuildConfig('capacitor'));
|
export default defineConfig(async () => {
|
||||||
|
const baseConfig = await createBuildConfig('capacitor');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
build: {
|
||||||
|
...baseConfig.build,
|
||||||
|
rollupOptions: {
|
||||||
|
...baseConfig.build?.rollupOptions,
|
||||||
|
// Note: @timesafari/daily-notification-plugin is NOT externalized
|
||||||
|
// because it needs to be bundled for dynamic imports to work in Capacitor WebView
|
||||||
|
output: {
|
||||||
|
...baseConfig.build?.rollupOptions?.output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user