Compare commits
24 Commits
sql-absurd
...
home-icon-
| Author | SHA1 | Date | |
|---|---|---|---|
| abc05d426e | |||
|
|
92b9c9334c | ||
|
|
706182ca0c | ||
|
|
68e0fc4976 | ||
| 504056eb90 | |||
| 5a1007c49c | |||
|
|
cbc14e21ec | ||
|
|
3e02b3924a | ||
|
|
8b03789941 | ||
|
|
b4a6b99301 | ||
|
|
e839997f91 | ||
|
|
d8d054a0e1 | ||
|
|
efc720e47f | ||
|
|
0a85bea533 | ||
|
|
47501ae917 | ||
|
|
28634839ec | ||
| 1b7c96ed9b | |||
| 41365fab8f | |||
| 5cc42be58a | |||
| 3d1a2eeb8d | |||
| 7b0ee2e44e | |||
| ac018997e8 | |||
| 6f449e9c1f | |||
| 543599a6a1 |
5
.gitignore
vendored
@@ -51,6 +51,7 @@ vendor/
|
|||||||
# Build logs
|
# Build logs
|
||||||
build_logs/
|
build_logs/
|
||||||
|
|
||||||
android/app/src/main/assets/public
|
# PWA icon files generated by capacitor-assets
|
||||||
android/app/src/main/res
|
icons
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -343,7 +343,11 @@ Prerequisites: macOS with Xcode installed
|
|||||||
3. Copy the assets:
|
3. Copy the assets:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# It makes no sense why capacitor-assets will not run without these but it actually changes the contents.
|
||||||
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
|
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
|
||||||
|
echo '{"images":[]}' > ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
|
||||||
|
mkdir -p ios/App/App/Assets.xcassets/Splash.imageset
|
||||||
|
echo '{"images":[]}' > ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
|
||||||
npx capacitor-assets generate --ios
|
npx capacitor-assets generate --ios
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari.app",
|
||||||
"appName": "TimeSafari",
|
"appName": "TimeSafari",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"bundledWebRuntime": false,
|
"bundledWebRuntime": false,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,5 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||||
|
</background>
|
||||||
|
<foreground>
|
||||||
|
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||||
|
</foreground>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 60 KiB |
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
13
ios/.gitignore
vendored
@@ -11,3 +11,16 @@ capacitor-cordova-ios-plugins
|
|||||||
# Generated Config files
|
# Generated Config files
|
||||||
App/App/capacitor.config.json
|
App/App/capacitor.config.json
|
||||||
App/App/config.xml
|
App/App/config.xml
|
||||||
|
|
||||||
|
# User-specific Xcode files
|
||||||
|
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
||||||
|
App/App.xcodeproj/*.xcuserstate
|
||||||
|
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
|
||||||
|
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
|
||||||
|
App/App/Assets.xcassets/AppIcon.appiconset
|
||||||
|
App/App/Assets.xcassets/Splash.imageset
|
||||||
|
|||||||
@@ -380,6 +380,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
@@ -406,6 +407,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 116 KiB |
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"idiom": "universal",
|
|
||||||
"size": "1024x1024",
|
|
||||||
"filename": "AppIcon-512@2x.png",
|
|
||||||
"platform": "ios"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info": {
|
|
||||||
"author": "xcode",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "splash-2732x2732-2.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "splash-2732x2732-1.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"filename" : "splash-2732x2732.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -165,7 +165,7 @@
|
|||||||
},
|
},
|
||||||
"main": "./dist-electron/main.js",
|
"main": "./dist-electron/main.js",
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari.app",
|
||||||
"productName": "TimeSafari",
|
"productName": "TimeSafari",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron-packages"
|
"output": "dist-electron-packages"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const { existsSync } = require('fs');
|
|||||||
*/
|
*/
|
||||||
function checkCommand(command, errorMessage) {
|
function checkCommand(command, errorMessage) {
|
||||||
try {
|
try {
|
||||||
execSync(command + ' --version', { stdio: 'ignore' });
|
execSync(command, { stdio: 'ignore' });
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`❌ ${errorMessage}`);
|
console.error(`❌ ${errorMessage}`);
|
||||||
@@ -164,10 +164,10 @@ function main() {
|
|||||||
|
|
||||||
// Check required command line tools
|
// Check required command line tools
|
||||||
// These are essential for building and testing the application
|
// These are essential for building and testing the application
|
||||||
success &= checkCommand('node', 'Node.js is required');
|
success &= checkCommand('node --version', 'Node.js is required');
|
||||||
success &= checkCommand('npm', 'npm is required');
|
success &= checkCommand('npm --version', 'npm is required');
|
||||||
success &= checkCommand('gradle', 'Gradle is required for Android builds');
|
success &= checkCommand('gradle --version', 'Gradle is required for Android builds');
|
||||||
success &= checkCommand('xcodebuild', 'Xcode is required for iOS builds');
|
success &= checkCommand('xcodebuild --help', 'Xcode is required for iOS builds');
|
||||||
|
|
||||||
// Check platform-specific development environments
|
// Check platform-specific development environments
|
||||||
success &= checkAndroidSetup();
|
success &= checkAndroidSetup();
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ const executeDeeplink = async (url, description, log) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Stop the app before executing the deep link
|
// Stop the app before executing the deep link
|
||||||
execSync('adb shell am force-stop app.timesafari');
|
execSync('adb shell am force-stop app.timesafari.app');
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
|
||||||
|
|
||||||
execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`);
|
execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`);
|
||||||
|
|||||||
@@ -14,22 +14,37 @@
|
|||||||
class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2"
|
class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div v-if="record.issuerDid">
|
<router-link
|
||||||
|
v-if="record.issuerDid && !isHiddenDid(record.issuerDid)"
|
||||||
|
:to="{
|
||||||
|
path: '/did/' + encodeURIComponent(record.issuerDid),
|
||||||
|
}"
|
||||||
|
title="More details about this person"
|
||||||
|
>
|
||||||
<EntityIcon
|
<EntityIcon
|
||||||
:entity-id="record.issuerDid"
|
:entity-id="record.issuerDid"
|
||||||
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
|
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</router-link>
|
||||||
<div v-else>
|
<font-awesome
|
||||||
<font-awesome
|
v-else-if="isHiddenDid(record.issuerDid)"
|
||||||
icon="person-circle-question"
|
icon="eye-slash"
|
||||||
class="text-slate-300 text-[2rem]"
|
class="text-slate-400 !size-[2rem] cursor-pointer"
|
||||||
/>
|
@click="notifyHiddenPerson"
|
||||||
</div>
|
/>
|
||||||
|
<font-awesome
|
||||||
|
v-else
|
||||||
|
icon="person-circle-question"
|
||||||
|
class="text-slate-400 !size-[2rem] cursor-pointer"
|
||||||
|
@click="notifyUnknownPerson"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold">
|
<h3
|
||||||
{{ record.issuer.known ? record.issuer.displayName : "" }}
|
v-if="record.issuer.known"
|
||||||
|
class="font-semibold leading-tight"
|
||||||
|
>
|
||||||
|
{{ record.issuer.displayName }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="ms-auto text-xs text-slate-500 italic">
|
<p class="ms-auto text-xs text-slate-500 italic">
|
||||||
{{ friendlyDate }}
|
{{ friendlyDate }}
|
||||||
@@ -46,7 +61,7 @@
|
|||||||
<!-- Record Image -->
|
<!-- Record Image -->
|
||||||
<div
|
<div
|
||||||
v-if="record.image"
|
v-if="record.image"
|
||||||
class="bg-cover mb-6 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
|
class="bg-cover mb-4 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
|
||||||
:style="`background-image: url(${record.image});`"
|
:style="`background-image: url(${record.image});`"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -63,33 +78,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-5"
|
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-3"
|
||||||
>
|
>
|
||||||
<!-- Source -->
|
<!-- Source -->
|
||||||
<div
|
<div
|
||||||
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||||
>
|
>
|
||||||
<div class="relative w-fit mx-auto">
|
<div class="relative w-fit mx-auto">
|
||||||
<div>
|
<div>
|
||||||
<!-- Project Icon -->
|
<!-- Project Icon -->
|
||||||
<div v-if="record.providerPlanName">
|
<div v-if="record.providerPlanName">
|
||||||
<ProjectIcon
|
<router-link
|
||||||
:entity-id="record.providerPlanName"
|
:to="{
|
||||||
:icon-size="48"
|
path: '/project/' + encodeURIComponent(record.providerPlanHandleId || ''),
|
||||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
}"
|
||||||
/>
|
title="View project details"
|
||||||
|
>
|
||||||
|
<ProjectIcon
|
||||||
|
:entity-id="record.providerPlanHandleId || ''"
|
||||||
|
:icon-size="48"
|
||||||
|
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||||
|
/>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<!-- Identicon for DIDs -->
|
<!-- Identicon for DIDs -->
|
||||||
<div v-else-if="record.agentDid">
|
<div v-else-if="record.agentDid">
|
||||||
<EntityIcon
|
<router-link
|
||||||
:entity-id="record.agentDid"
|
v-if="!isHiddenDid(record.agentDid)"
|
||||||
:profile-image-url="record.issuer.profileImageUrl"
|
:to="{
|
||||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
path: '/did/' + encodeURIComponent(record.agentDid),
|
||||||
|
}"
|
||||||
|
title="More details about this person"
|
||||||
|
>
|
||||||
|
<EntityIcon
|
||||||
|
:entity-id="record.agentDid"
|
||||||
|
:profile-image-url="record.issuer.profileImageUrl"
|
||||||
|
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||||
|
/>
|
||||||
|
</router-link>
|
||||||
|
<font-awesome
|
||||||
|
v-else
|
||||||
|
@click="notifyHiddenPerson"
|
||||||
|
icon="eye-slash"
|
||||||
|
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Unknown Person -->
|
<!-- Unknown Person -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
|
@click="notifyUnknownPerson"
|
||||||
icon="person-circle-question"
|
icon="person-circle-question"
|
||||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||||
/>
|
/>
|
||||||
@@ -110,9 +147,9 @@
|
|||||||
|
|
||||||
<!-- Arrow -->
|
<!-- Arrow -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-x-[8rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
|
class="absolute inset-x-[7rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
|
||||||
>
|
>
|
||||||
<div class="text-sm text-center leading-none font-semibold pe-[15px]">
|
<div class="text-sm text-center leading-none font-semibold pe-2 sm:pe-4">
|
||||||
{{ fetchAmount }}
|
{{ fetchAmount }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,29 +166,51 @@
|
|||||||
|
|
||||||
<!-- Destination -->
|
<!-- Destination -->
|
||||||
<div
|
<div
|
||||||
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||||
>
|
>
|
||||||
<div class="relative w-fit mx-auto">
|
<div class="relative w-fit mx-auto">
|
||||||
<div>
|
<div>
|
||||||
<!-- Project Icon -->
|
<!-- Project Icon -->
|
||||||
<div v-if="record.recipientProjectName">
|
<div v-if="record.recipientProjectName">
|
||||||
<ProjectIcon
|
<router-link
|
||||||
:entity-id="record.recipientProjectName"
|
:to="{
|
||||||
:icon-size="48"
|
path: '/project/' + encodeURIComponent(record.fulfillsPlanHandleId || ''),
|
||||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
}"
|
||||||
/>
|
title="View project details"
|
||||||
|
>
|
||||||
|
<ProjectIcon
|
||||||
|
:entity-id="record.fulfillsPlanHandleId || ''"
|
||||||
|
:icon-size="48"
|
||||||
|
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||||
|
/>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<!-- Identicon for DIDs -->
|
<!-- Identicon for DIDs -->
|
||||||
<div v-else-if="record.recipientDid">
|
<div v-else-if="record.recipientDid">
|
||||||
<EntityIcon
|
<router-link
|
||||||
:entity-id="record.recipientDid"
|
v-if="!isHiddenDid(record.recipientDid)"
|
||||||
:profile-image-url="record.receiver.profileImageUrl"
|
:to="{
|
||||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
path: '/did/' + encodeURIComponent(record.recipientDid),
|
||||||
|
}"
|
||||||
|
title="More details about this person"
|
||||||
|
>
|
||||||
|
<EntityIcon
|
||||||
|
:entity-id="record.recipientDid"
|
||||||
|
:profile-image-url="record.receiver.profileImageUrl"
|
||||||
|
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||||
|
/>
|
||||||
|
</router-link>
|
||||||
|
<font-awesome
|
||||||
|
v-else
|
||||||
|
@click="notifyHiddenPerson"
|
||||||
|
icon="eye-slash"
|
||||||
|
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Unknown Person -->
|
<!-- Unknown Person -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
|
@click="notifyUnknownPerson"
|
||||||
icon="person-circle-question"
|
icon="person-circle-question"
|
||||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||||
/>
|
/>
|
||||||
@@ -186,8 +245,9 @@ import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
|||||||
import { GiveRecordWithContactInfo } from "../types";
|
import { GiveRecordWithContactInfo } from "../types";
|
||||||
import EntityIcon from "./EntityIcon.vue";
|
import EntityIcon from "./EntityIcon.vue";
|
||||||
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
||||||
import { containsHiddenDid } from "../libs/endorserServer";
|
import { containsHiddenDid, isHiddenDid } from "../libs/endorserServer";
|
||||||
import ProjectIcon from "./ProjectIcon.vue";
|
import ProjectIcon from "./ProjectIcon.vue";
|
||||||
|
import { NotificationIface } from "../constants/app";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
@@ -202,6 +262,33 @@ export default class ActivityListItem extends Vue {
|
|||||||
@Prop() activeDid!: string;
|
@Prop() activeDid!: string;
|
||||||
@Prop() confirmerIdList?: string[];
|
@Prop() confirmerIdList?: string[];
|
||||||
|
|
||||||
|
isHiddenDid = isHiddenDid;
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
notifyHiddenPerson() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Person Outside Your Network",
|
||||||
|
text: "This person is not visible to you.",
|
||||||
|
},
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyUnknownPerson() {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "warning",
|
||||||
|
title: "Unidentified Person",
|
||||||
|
text: "Nobody specific was recognized.",
|
||||||
|
},
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Emit()
|
@Emit()
|
||||||
cacheImage(image: string) {
|
cacheImage(image: string) {
|
||||||
return image;
|
return image;
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default class DataExportSection extends Vue {
|
|||||||
transform: (table, value, key) => {
|
transform: (table, value, key) => {
|
||||||
if (table === "contacts") {
|
if (table === "contacts") {
|
||||||
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
|
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
|
||||||
Object.keys(value).forEach(prop => {
|
Object.keys(value).forEach((prop) => {
|
||||||
if (value[prop] === undefined) {
|
if (value[prop] === undefined) {
|
||||||
delete value[prop];
|
delete value[prop];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="text-lg text-center font-bold relative">
|
<div class="text-lg text-center font-bold relative">
|
||||||
<h1 id="ViewHeading" class="text-center font-bold">
|
<h1 id="ViewHeading" class="text-center font-bold">
|
||||||
<span v-if="uploading">Uploading Image…</span>
|
<span v-if="uploading">Uploading Image…</span>
|
||||||
<span v-else-if="blob">Crop Image</span>
|
<span v-else-if="blob">{{ crop ? 'Crop Image' : 'Preview Image' }}</span>
|
||||||
<span v-else-if="showCameraPreview">Upload Image</span>
|
<span v-else-if="showCameraPreview">Upload Image</span>
|
||||||
<span v-else>Add Photo</span>
|
<span v-else>Add Photo</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -119,12 +119,21 @@
|
|||||||
playsinline
|
playsinline
|
||||||
muted
|
muted
|
||||||
></video>
|
></video>
|
||||||
<button
|
<div class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4">
|
||||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
<button
|
||||||
@click="capturePhoto"
|
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||||
>
|
@click="capturePhoto"
|
||||||
<font-awesome icon="camera" class="w-[1em]" />
|
>
|
||||||
</button>
|
<font-awesome icon="camera" class="w-[1em]" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="platformCapabilities.isMobile"
|
||||||
|
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||||
|
@click="rotateCamera"
|
||||||
|
>
|
||||||
|
<font-awesome icon="rotate" class="w-[1em]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -262,6 +271,11 @@ const inputImageFileNameRef = ref<Blob>();
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
defaultCameraMode: {
|
||||||
|
type: String,
|
||||||
|
default: 'environment',
|
||||||
|
validator: (value: string) => ['environment', 'user'].includes(value)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class ImageMethodDialog extends Vue {
|
export default class ImageMethodDialog extends Vue {
|
||||||
@@ -303,6 +317,9 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
/** Camera stream reference */
|
/** Camera stream reference */
|
||||||
private cameraStream: MediaStream | null = null;
|
private cameraStream: MediaStream | null = null;
|
||||||
|
|
||||||
|
/** Current camera facing mode */
|
||||||
|
private currentFacingMode: 'environment' | 'user' = 'environment';
|
||||||
|
|
||||||
private platformService = PlatformServiceFactory.getInstance();
|
private platformService = PlatformServiceFactory.getInstance();
|
||||||
URL = window.URL || window.webkitURL;
|
URL = window.URL || window.webkitURL;
|
||||||
|
|
||||||
@@ -362,15 +379,16 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||||
|
logger.debug("ImageMethodDialog.open called");
|
||||||
this.claimType = claimType;
|
this.claimType = claimType;
|
||||||
this.crop = !!crop;
|
this.crop = !!crop;
|
||||||
this.imageCallback = setImageFn;
|
this.imageCallback = setImageFn;
|
||||||
this.visible = true;
|
this.visible = true;
|
||||||
|
this.currentFacingMode = this.defaultCameraMode as 'environment' | 'user';
|
||||||
|
|
||||||
// Start camera preview immediately if not on mobile
|
// Start camera preview immediately
|
||||||
if (!this.platformCapabilities.isNativeApp) {
|
logger.debug("Starting camera preview from open()");
|
||||||
this.startCameraPreview();
|
this.startCameraPreview();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadImageFile(event: Event) {
|
async uploadImageFile(event: Event) {
|
||||||
@@ -439,46 +457,21 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
logger.debug("startCameraPreview called");
|
logger.debug("startCameraPreview called");
|
||||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||||
|
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
|
||||||
|
logger.debug("getUserMedia available:", !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia));
|
||||||
|
|
||||||
if (this.platformCapabilities.isNativeApp) {
|
|
||||||
logger.debug("Using platform service for mobile device");
|
|
||||||
this.cameraState = "initializing";
|
|
||||||
this.cameraStateMessage = "Using platform camera service...";
|
|
||||||
try {
|
|
||||||
const result = await this.platformService.takePicture();
|
|
||||||
this.blob = result.blob;
|
|
||||||
this.fileName = result.fileName;
|
|
||||||
this.cameraState = "ready";
|
|
||||||
this.cameraStateMessage = "Photo captured successfully";
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error taking picture:", error);
|
|
||||||
this.cameraState = "error";
|
|
||||||
this.cameraStateMessage =
|
|
||||||
error instanceof Error ? error.message : "Failed to take picture";
|
|
||||||
this.error =
|
|
||||||
error instanceof Error ? error.message : "Failed to take picture";
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "Failed to take picture. Please try again.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Starting camera preview for desktop browser");
|
|
||||||
try {
|
try {
|
||||||
this.cameraState = "initializing";
|
this.cameraState = "initializing";
|
||||||
this.cameraStateMessage = "Requesting camera access...";
|
this.cameraStateMessage = "Requesting camera access...";
|
||||||
this.showCameraPreview = true;
|
this.showCameraPreview = true;
|
||||||
await this.$nextTick();
|
await this.$nextTick();
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
throw new Error("Camera API not available in this browser");
|
||||||
|
}
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: { facingMode: "environment" },
|
video: { facingMode: this.currentFacingMode },
|
||||||
});
|
});
|
||||||
logger.debug("Camera access granted");
|
logger.debug("Camera access granted");
|
||||||
this.cameraStream = stream;
|
this.cameraStream = stream;
|
||||||
@@ -493,31 +486,41 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
videoElement.onloadedmetadata = () => {
|
videoElement.onloadedmetadata = () => {
|
||||||
videoElement.play().then(() => {
|
videoElement.play().then(() => {
|
||||||
|
logger.debug("Video element started playing");
|
||||||
resolve(true);
|
resolve(true);
|
||||||
|
}).catch(error => {
|
||||||
|
logger.error("Error playing video:", error);
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.error("Video element not found");
|
||||||
|
throw new Error("Video element not found");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error starting camera preview:", error);
|
logger.error("Error starting camera preview:", error);
|
||||||
let errorMessage =
|
let errorMessage =
|
||||||
error instanceof Error ? error.message : "Failed to access camera";
|
error instanceof Error ? error.message : "Failed to access camera";
|
||||||
if (
|
if (
|
||||||
|
error instanceof Error && (
|
||||||
error.name === "NotReadableError" ||
|
error.name === "NotReadableError" ||
|
||||||
error.name === "TrackStartError"
|
error.name === "TrackStartError"
|
||||||
) {
|
)) {
|
||||||
errorMessage =
|
errorMessage =
|
||||||
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
|
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
|
||||||
} else if (
|
} else if (
|
||||||
|
error instanceof Error && (
|
||||||
error.name === "NotAllowedError" ||
|
error.name === "NotAllowedError" ||
|
||||||
error.name === "PermissionDeniedError"
|
error.name === "PermissionDeniedError"
|
||||||
) {
|
)) {
|
||||||
errorMessage =
|
errorMessage =
|
||||||
"Camera access was denied. Please allow camera access in your browser settings.";
|
"Camera access was denied. Please allow camera access in your browser settings.";
|
||||||
}
|
}
|
||||||
this.cameraState = "error";
|
this.cameraState = "error";
|
||||||
this.cameraStateMessage = errorMessage;
|
this.cameraStateMessage = errorMessage;
|
||||||
this.error = errorMessage;
|
this.error = errorMessage;
|
||||||
|
this.showCameraPreview = false;
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -527,7 +530,6 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
},
|
},
|
||||||
5000,
|
5000,
|
||||||
);
|
);
|
||||||
this.showCameraPreview = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +581,20 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async rotateCamera() {
|
||||||
|
// Toggle between front and back cameras
|
||||||
|
this.currentFacingMode = this.currentFacingMode === 'environment' ? 'user' : 'environment';
|
||||||
|
|
||||||
|
// Stop current stream
|
||||||
|
if (this.cameraStream) {
|
||||||
|
this.cameraStream.getTracks().forEach(track => track.stop());
|
||||||
|
this.cameraStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new stream with updated facing mode
|
||||||
|
await this.startCameraPreview();
|
||||||
|
}
|
||||||
|
|
||||||
private createBlobURL(blob: Blob): string {
|
private createBlobURL(blob: Blob): string {
|
||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
@@ -613,6 +629,7 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
5000,
|
5000,
|
||||||
);
|
);
|
||||||
this.uploading = false;
|
this.uploading = false;
|
||||||
|
this.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||||
@@ -667,6 +684,7 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
);
|
);
|
||||||
this.uploading = false;
|
this.uploading = false;
|
||||||
this.blob = undefined;
|
this.blob = undefined;
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export default class MembersList extends Vue {
|
|||||||
this.decryptedMembers.length === 0 ||
|
this.decryptedMembers.length === 0 ||
|
||||||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
|
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
|
||||||
) {
|
) {
|
||||||
return "Your password is not the same as the organizer. Reload or have them check their password.";
|
return "Your password is not the same as the organizer. Retry or have them check their password.";
|
||||||
} else {
|
} else {
|
||||||
// the first (organizer) member was decrypted OK
|
// the first (organizer) member was decrypted OK
|
||||||
return "";
|
return "";
|
||||||
@@ -337,7 +337,7 @@ export default class MembersList extends Vue {
|
|||||||
group: "alert",
|
group: "alert",
|
||||||
type: "info",
|
type: "info",
|
||||||
title: "Contact Exists",
|
title: "Contact Exists",
|
||||||
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
|
text: "They are in your contacts. To remove them, use the contacts page.",
|
||||||
},
|
},
|
||||||
10000,
|
10000,
|
||||||
);
|
);
|
||||||
@@ -347,7 +347,7 @@ export default class MembersList extends Vue {
|
|||||||
group: "alert",
|
group: "alert",
|
||||||
type: "info",
|
type: "info",
|
||||||
title: "Contact Available",
|
title: "Contact Available",
|
||||||
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
|
text: "This is to add them to your contacts. To remove them later, use the contacts page.",
|
||||||
},
|
},
|
||||||
10000,
|
10000,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ db.on("populate", async () => {
|
|||||||
try {
|
try {
|
||||||
await db.settings.add(DEFAULT_SETTINGS);
|
await db.settings.add(DEFAULT_SETTINGS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error populating the database with default settings:", error);
|
console.error(
|
||||||
|
"Error populating the database with default settings:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +108,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
|||||||
|
|
||||||
// Create a promise that rejects after 5 seconds
|
// Create a promise that rejects after 5 seconds
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
setTimeout(() => reject(new Error('Database open timed out')), 500);
|
setTimeout(() => reject(new Error("Database open timed out")), 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Race between the open operation and the timeout
|
// Race between the open operation and the timeout
|
||||||
@@ -123,7 +126,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
|||||||
console.error(`Attempt ${i + 1}: Database open failed:`, error);
|
console.error(`Attempt ${i + 1}: Database open failed:`, error);
|
||||||
if (i < retries - 1) {
|
if (i < retries - 1) {
|
||||||
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
|
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -145,10 +148,16 @@ export async function updateDefaultSettings(
|
|||||||
await safeOpenDatabase();
|
await safeOpenDatabase();
|
||||||
} catch (openError: unknown) {
|
} catch (openError: unknown) {
|
||||||
console.error("Failed to open database:", openError);
|
console.error("Failed to open database:", openError);
|
||||||
const errorMessage = openError instanceof Error ? openError.message : String(openError);
|
const errorMessage =
|
||||||
throw new Error(`Database connection failed: ${errorMessage}. Please try again or restart the app.`);
|
openError instanceof Error ? openError.message : String(openError);
|
||||||
|
throw new Error(
|
||||||
|
`Database connection failed: ${errorMessage}. Please try again or restart the app.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const result = await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
|
const result = await db.settings.update(
|
||||||
|
MASTER_SETTINGS_KEY,
|
||||||
|
settingsChanges,
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating default settings:", error);
|
console.error("Error updating default settings:", error);
|
||||||
|
|||||||
@@ -549,11 +549,13 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
|||||||
mnemonic: mnemonic,
|
mnemonic: mnemonic,
|
||||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateDefaultSettings({ activeDid: newId.did });
|
await updateDefaultSettings({ activeDid: newId.did });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update default settings:", error);
|
console.error("Failed to update default settings:", error);
|
||||||
throw new Error("Failed to set default settings. Please try again or restart the app.");
|
throw new Error(
|
||||||
|
"Failed to set default settings. Please try again or restart the app.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await updateAccountSettings(newId.did, { isRegistered: false });
|
await updateAccountSettings(newId.did, { isRegistered: false });
|
||||||
return newId.did;
|
return newId.did;
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface PlatformCapabilities {
|
|||||||
hasFileDownload: boolean;
|
hasFileDownload: boolean;
|
||||||
/** Whether the platform requires special file handling instructions */
|
/** Whether the platform requires special file handling instructions */
|
||||||
needsFileHandlingInstructions: boolean;
|
needsFileHandlingInstructions: boolean;
|
||||||
|
/** Whether the platform is a native app (Capacitor, Electron, etc.) */
|
||||||
|
isNativeApp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +94,12 @@ export interface PlatformService {
|
|||||||
*/
|
*/
|
||||||
pickImage(): Promise<ImageResult>;
|
pickImage(): Promise<ImageResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates the camera between front and back cameras.
|
||||||
|
* @returns Promise that resolves when the camera is rotated
|
||||||
|
*/
|
||||||
|
rotateCamera(): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deep link URLs for the application.
|
* Handles deep link URLs for the application.
|
||||||
* @param url - The deep link URL to handle
|
* @param url - The deep link URL to handle
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||||
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
|
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
|
||||||
import { Share } from "@capacitor/share";
|
import { Share } from "@capacitor/share";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
|
|
||||||
@@ -16,6 +16,9 @@ import { logger } from "../../utils/logger";
|
|||||||
* - Platform-specific features
|
* - Platform-specific features
|
||||||
*/
|
*/
|
||||||
export class CapacitorPlatformService implements PlatformService {
|
export class CapacitorPlatformService implements PlatformService {
|
||||||
|
/** Current camera direction */
|
||||||
|
private currentDirection: CameraDirection = 'BACK';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the capabilities of the Capacitor platform
|
* Gets the capabilities of the Capacitor platform
|
||||||
* @returns Platform capabilities object
|
* @returns Platform capabilities object
|
||||||
@@ -28,6 +31,7 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||||
hasFileDownload: false,
|
hasFileDownload: false,
|
||||||
needsFileHandlingInstructions: true,
|
needsFileHandlingInstructions: true,
|
||||||
|
isNativeApp: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +405,7 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
allowEditing: true,
|
allowEditing: true,
|
||||||
resultType: CameraResultType.Base64,
|
resultType: CameraResultType.Base64,
|
||||||
source: CameraSource.Camera,
|
source: CameraSource.Camera,
|
||||||
|
direction: this.currentDirection,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await this.processImageData(image.base64String);
|
const blob = await this.processImageData(image.base64String);
|
||||||
@@ -466,6 +471,15 @@ export class CapacitorPlatformService implements PlatformService {
|
|||||||
return new Blob(byteArrays, { type: "image/jpeg" });
|
return new Blob(byteArrays, { type: "image/jpeg" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates the camera between front and back cameras.
|
||||||
|
* @returns Promise that resolves when the camera is rotated
|
||||||
|
*/
|
||||||
|
async rotateCamera(): Promise<void> {
|
||||||
|
this.currentDirection = this.currentDirection === 'BACK' ? 'FRONT' : 'BACK';
|
||||||
|
logger.debug(`Camera rotated to ${this.currentDirection} camera`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deep link URLs for the application.
|
* Handles deep link URLs for the application.
|
||||||
* Note: Capacitor handles deep links automatically.
|
* Note: Capacitor handles deep links automatically.
|
||||||
|
|||||||
@@ -115,6 +115,7 @@
|
|||||||
<ImageMethodDialog
|
<ImageMethodDialog
|
||||||
ref="imageMethodDialog"
|
ref="imageMethodDialog"
|
||||||
:is-registered="isRegistered"
|
:is-registered="isRegistered"
|
||||||
|
default-camera-mode="user"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|||||||
@@ -357,7 +357,8 @@ export default class ContactQRScan extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contactInfo = decodedJwt.payload.own;
|
const contactInfo = decodedJwt.payload.own;
|
||||||
if (!contactInfo.did) {
|
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||||
|
if (!did) {
|
||||||
logger.warn("Invalid contact info - missing DID");
|
logger.warn("Invalid contact info - missing DID");
|
||||||
this.$notify({
|
this.$notify({
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -370,7 +371,7 @@ export default class ContactQRScan extends Vue {
|
|||||||
|
|
||||||
// Create contact object
|
// Create contact object
|
||||||
const contact = {
|
const contact = {
|
||||||
did: contactInfo.did,
|
did: did,
|
||||||
name: contactInfo.name || "",
|
name: contactInfo.name || "",
|
||||||
email: contactInfo.email || "",
|
email: contactInfo.email || "",
|
||||||
phone: contactInfo.phone || "",
|
phone: contactInfo.phone || "",
|
||||||
|
|||||||
@@ -152,30 +152,6 @@
|
|||||||
@camera-on="onCameraOn"
|
@camera-on="onCameraOn"
|
||||||
@camera-off="onCameraOff"
|
@camera-off="onCameraOff"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute bottom-4 inset-x-0 flex justify-center items-center"
|
|
||||||
>
|
|
||||||
<!-- Camera Stop Button -->
|
|
||||||
<button
|
|
||||||
class="text-center text-slate-600 leading-none bg-white p-2 rounded-full drop-shadow-lg"
|
|
||||||
title="Stop camera"
|
|
||||||
@click="stopScanning"
|
|
||||||
>
|
|
||||||
<font-awesome icon="xmark" class="size-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex items-center justify-center aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white text-lg px-3 py-2 rounded-lg"
|
|
||||||
@click="startScanning"
|
|
||||||
>
|
|
||||||
Scan QR Code
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -258,6 +234,7 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
|
|
||||||
// Add property to track if we're on desktop
|
// Add property to track if we're on desktop
|
||||||
private isDesktop = false;
|
private isDesktop = false;
|
||||||
|
private isFrontCamera = false;
|
||||||
|
|
||||||
async created() {
|
async created() {
|
||||||
try {
|
try {
|
||||||
@@ -506,7 +483,8 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contactInfo = decodedJwt.payload.own;
|
const contactInfo = decodedJwt.payload.own;
|
||||||
if (!contactInfo.did) {
|
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||||
|
if (!did) {
|
||||||
logger.warn("Invalid contact info - missing DID");
|
logger.warn("Invalid contact info - missing DID");
|
||||||
this.$notify({
|
this.$notify({
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -519,7 +497,7 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
|
|
||||||
// Create contact object
|
// Create contact object
|
||||||
const contact = {
|
const contact = {
|
||||||
did: contactInfo.did,
|
did: did,
|
||||||
name: contactInfo.name || "",
|
name: contactInfo.name || "",
|
||||||
email: contactInfo.email || "",
|
email: contactInfo.email || "",
|
||||||
phone: contactInfo.phone || "",
|
phone: contactInfo.phone || "",
|
||||||
@@ -723,14 +701,6 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
document.addEventListener("resume", this.handleAppResume);
|
document.addEventListener("resume", this.handleAppResume);
|
||||||
// Start scanning automatically when view is loaded
|
// Start scanning automatically when view is loaded
|
||||||
this.startScanning();
|
this.startScanning();
|
||||||
|
|
||||||
// Apply mirroring after a short delay to ensure video element is ready
|
|
||||||
setTimeout(() => {
|
|
||||||
const videoElement = document.querySelector('.qr-scanner video') as HTMLVideoElement;
|
|
||||||
if (videoElement) {
|
|
||||||
videoElement.style.transform = 'scaleX(-1)';
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
@@ -877,6 +847,8 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
onCameraOn(): void {
|
onCameraOn(): void {
|
||||||
this.cameraState = "active";
|
this.cameraState = "active";
|
||||||
this.isInitializing = false;
|
this.isInitializing = false;
|
||||||
|
this.isFrontCamera = this.preferredCamera === "user";
|
||||||
|
this.applyCameraMirroring();
|
||||||
}
|
}
|
||||||
|
|
||||||
onCameraOff(): void {
|
onCameraOff(): void {
|
||||||
@@ -922,6 +894,8 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
toggleCamera(): void {
|
toggleCamera(): void {
|
||||||
this.preferredCamera =
|
this.preferredCamera =
|
||||||
this.preferredCamera === "user" ? "environment" : "user";
|
this.preferredCamera === "user" ? "environment" : "user";
|
||||||
|
this.isFrontCamera = this.preferredCamera === "user";
|
||||||
|
this.applyCameraMirroring();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleError(error: unknown): void {
|
private handleError(error: unknown): void {
|
||||||
@@ -943,17 +917,21 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
// Add method to detect desktop browser
|
// Add method to detect desktop browser
|
||||||
private detectDesktopBrowser(): boolean {
|
private detectDesktopBrowser(): boolean {
|
||||||
const userAgent = navigator.userAgent.toLowerCase();
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
|
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the computed property for camera mirroring
|
// Add method to apply camera mirroring
|
||||||
get shouldMirrorCamera(): boolean {
|
private applyCameraMirroring(): void {
|
||||||
// On desktop, always mirror the webcam
|
const videoElement = document.querySelector(
|
||||||
if (this.isDesktop) {
|
".qr-scanner video",
|
||||||
return true;
|
) as HTMLVideoElement;
|
||||||
|
if (videoElement) {
|
||||||
|
// Mirror if it's desktop or front camera on mobile
|
||||||
|
const shouldMirror = this.isDesktop || (this.isFrontCamera && !this.isDesktop);
|
||||||
|
videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none";
|
||||||
}
|
}
|
||||||
// On mobile, mirror only for front-facing camera
|
|
||||||
return this.preferredCamera === "user";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -968,8 +946,9 @@ export default class ContactQRScanShow extends Vue {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remove the default mirroring from CSS since we're handling it in JavaScript */
|
||||||
:deep(.qr-scanner video) {
|
:deep(.qr-scanner video) {
|
||||||
transform: scaleX(-1);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure the canvas for QR detection is not mirrored */
|
/* Ensure the canvas for QR detection is not mirrored */
|
||||||
|
|||||||
@@ -54,17 +54,12 @@
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="flex items-center 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-1.5 py-1 mr-1 rounded-md"
|
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||||
>
|
>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
icon="chair"
|
icon="chair"
|
||||||
class="fa-fw text-2xl"
|
class="fa-fw text-2xl"
|
||||||
@click="
|
@click="this.$router.push({ name: 'onboard-meeting-list' })"
|
||||||
warning(
|
|
||||||
'You must get registered before you can initiate an onboarding meeting.',
|
|
||||||
'Not Registered',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ImageMethodDialog ref="imageDialog" />
|
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||||
|
|
||||||
<div class="mt-4 flex justify-between gap-2">
|
<div class="mt-4 flex justify-between gap-2">
|
||||||
<!-- First Column for Giver -->
|
<!-- First Column for Giver -->
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ImageMethodDialog ref="imageDialog" />
|
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
v-model="agentDid"
|
v-model="agentDid"
|
||||||
|
|||||||
@@ -34,9 +34,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="hitError">
|
<div v-else-if="hitError">
|
||||||
<span class="text-xl">Error Creating Identity</span>
|
<span class="text-xl">Error Creating Identity</span>
|
||||||
<font-awesome icon="exclamation-triangle" class="fa-fw text-red-500 ml-2"></font-awesome>
|
<font-awesome
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
class="fa-fw text-red-500 ml-2"
|
||||||
|
></font-awesome>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
Try fully restarting the app. If that doesn't work, back up all data (identities and other data) and reinstall the app.
|
Try fully restarting the app. If that doesn't work, back up all data
|
||||||
|
(identities and other data) and reinstall the app.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@@ -85,7 +89,7 @@ export default class NewIdentifierView extends Vue {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.hitError = true;
|
this.hitError = true;
|
||||||
console.error('Failed to generate identity:', error);
|
console.error("Failed to generate identity:", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,16 @@
|
|||||||
|
|
||||||
<!-- Members List -->
|
<!-- Members List -->
|
||||||
<MembersList v-else :password="password" @error="handleError" />
|
<MembersList v-else :password="password" @error="handleError" />
|
||||||
|
|
||||||
|
<!-- Project Link Section -->
|
||||||
|
<div v-if="projectLink" class="mt-8 p-4 border rounded-lg bg-white shadow">
|
||||||
|
<router-link
|
||||||
|
:to="'/project/' + encodeURIComponent(projectLink)"
|
||||||
|
class="text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Go To Project Page
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<UserNameDialog
|
<UserNameDialog
|
||||||
@@ -69,6 +79,7 @@ export default class OnboardMeetingMembersView extends Vue {
|
|||||||
firstName = "";
|
firstName = "";
|
||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
projectLink = "";
|
||||||
$route!: RouteLocationNormalizedLoaded;
|
$route!: RouteLocationNormalizedLoaded;
|
||||||
$router!: Router;
|
$router!: Router;
|
||||||
|
|
||||||
@@ -85,10 +96,12 @@ export default class OnboardMeetingMembersView extends Vue {
|
|||||||
async created() {
|
async created() {
|
||||||
if (!this.groupId) {
|
if (!this.groupId) {
|
||||||
this.errorMessage = "The group info is missing. Go back and try again.";
|
this.errorMessage = "The group info is missing. Go back and try again.";
|
||||||
|
this.isLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.password) {
|
if (!this.password) {
|
||||||
this.errorMessage = "The password is missing. Go back and try again.";
|
this.errorMessage = "The password is missing. Go back and try again.";
|
||||||
|
this.isLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const settings = await retrieveSettingsForActiveAccount();
|
const settings = await retrieveSettingsForActiveAccount();
|
||||||
@@ -129,6 +142,15 @@ export default class OnboardMeetingMembersView extends Vue {
|
|||||||
// updateMemberInMeeting sets isLoading to false
|
// updateMemberInMeeting sets isLoading to false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch the meeting details to get the project link
|
||||||
|
const meetingResponse = await this.axios.get(
|
||||||
|
`${this.apiServer}/api/partner/groupOnboard/${this.groupId}`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
if (meetingResponse.data?.data?.projectLink) {
|
||||||
|
this.projectLink = meetingResponse.data.data.projectLink;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.errorMessage =
|
this.errorMessage =
|
||||||
serverMessageForUser(error) ||
|
serverMessageForUser(error) ||
|
||||||
|
|||||||
@@ -49,12 +49,13 @@
|
|||||||
|
|
||||||
<div v-if="currentMeeting.password" class="mt-4">
|
<div v-if="currentMeeting.password" class="mt-4">
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
Share the password with the people you want to onboard.
|
Share the password with the members. You can also send them the
|
||||||
|
"shortcut page for members" link below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-red-600">
|
<div v-else class="text-red-600">
|
||||||
Your copy of the password is not saved. Edit the meeting, or delete it
|
You must reenter your password. Edit this meeting, or delete it and
|
||||||
and create a new meeting.
|
create a new meeting.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
v-if="
|
v-if="
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
isInEditOrCreateMode() &&
|
isInEditOrCreateMode() &&
|
||||||
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
|
newOrUpdatedMeetingInputs != null /* duplicate check is for typechecks */
|
||||||
"
|
"
|
||||||
class="mt-8"
|
class="mt-8"
|
||||||
>
|
>
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="meetingName"
|
id="meetingName"
|
||||||
v-model="newOrUpdatedMeeting.name"
|
v-model="newOrUpdatedMeetingInputs.name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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"
|
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"
|
||||||
@@ -131,7 +132,7 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="expirationTime"
|
id="expirationTime"
|
||||||
v-model="newOrUpdatedMeeting.expiresAt"
|
v-model="newOrUpdatedMeetingInputs.expiresAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
required
|
required
|
||||||
:min="minDateTime"
|
:min="minDateTime"
|
||||||
@@ -145,7 +146,7 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
v-model="newOrUpdatedMeeting.password"
|
v-model="newOrUpdatedMeetingInputs.password"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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"
|
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"
|
||||||
@@ -159,7 +160,7 @@
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="userName"
|
id="userName"
|
||||||
v-model="newOrUpdatedMeeting.userFullName"
|
v-model="newOrUpdatedMeetingInputs.userFullName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
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"
|
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"
|
||||||
@@ -167,6 +168,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="projectLink" class="block text-sm font-medium text-gray-700"
|
||||||
|
>Project Link</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="projectLink"
|
||||||
|
v-model="newOrUpdatedMeetingInputs.projectLink"
|
||||||
|
type="text"
|
||||||
|
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
placeholder="Project ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md hover:from-green-500 hover:to-green-800"
|
class="w-full bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md hover:from-green-500 hover:to-green-800"
|
||||||
@@ -201,15 +215,25 @@
|
|||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-2xl">Meeting Members</h2>
|
<h2 class="text-2xl">Meeting Members</h2>
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<div
|
||||||
v-if="!!currentMeeting.password"
|
class="flex items-center gap-2 cursor-pointer text-blue-600"
|
||||||
:to="onboardMeetingMembersLink()"
|
@click="copyMembersLinkToClipboard"
|
||||||
class="inline-block text-blue-600"
|
title="Click to copy link for members"
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
• Open shortcut page for members
|
<span>
|
||||||
<font-awesome icon="external-link" />
|
• Page for Members
|
||||||
</router-link>
|
<font-awesome icon="link" />
|
||||||
|
</span>
|
||||||
|
<router-link
|
||||||
|
v-if="!!currentMeeting.password"
|
||||||
|
:to="onboardMeetingMembersLink()"
|
||||||
|
class="inline-block text-blue-600 ml-4"
|
||||||
|
target="_blank"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<font-awesome icon="external-link" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MembersList
|
<MembersList
|
||||||
:password="currentMeeting.password || ''"
|
:password="currentMeeting.password || ''"
|
||||||
@@ -219,6 +243,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="currentMeeting?.projectLink"
|
||||||
|
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
||||||
|
>
|
||||||
|
<!-- Project Link Section -->
|
||||||
|
<div>
|
||||||
|
<router-link
|
||||||
|
:to="'/project/' + encodeURIComponent(currentMeeting.projectLink)"
|
||||||
|
class="text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Go To Project Page
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="isLoading">
|
<div v-else-if="isLoading">
|
||||||
<div class="flex justify-center items-center h-full">
|
<div class="flex justify-center items-center h-full">
|
||||||
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||||
@@ -229,6 +268,8 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
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";
|
||||||
@@ -240,19 +281,22 @@ import {
|
|||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import { encryptMessage } from "../libs/crypto";
|
import { encryptMessage } from "../libs/crypto";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
interface ServerMeeting {
|
interface ServerMeeting {
|
||||||
groupId: number; // from the server
|
groupId: number; // from the server
|
||||||
name: string; // from the server
|
name: string; // to & from the server
|
||||||
expiresAt: string; // from the server
|
expiresAt: string; // to & from the server
|
||||||
userFullName?: string; // from the user's session
|
userFullName?: string; // from the user's session
|
||||||
password?: string; // from the user's session
|
password?: string; // from the user's session
|
||||||
|
projectLink?: string; // to & from the server
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeetingSetupInfo {
|
interface MeetingSetupInputs {
|
||||||
name: string;
|
name: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
userFullName: string;
|
userFullName: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
projectLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -269,7 +313,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
currentMeeting: ServerMeeting | null = null;
|
currentMeeting: ServerMeeting | null = null;
|
||||||
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
|
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
isDeleting = false;
|
isDeleting = false;
|
||||||
@@ -295,11 +339,11 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isInCreateMode(): boolean {
|
isInCreateMode(): boolean {
|
||||||
return this.newOrUpdatedMeeting != null && this.currentMeeting == null;
|
return this.newOrUpdatedMeetingInputs != null && this.currentMeeting == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
isInEditOrCreateMode(): boolean {
|
isInEditOrCreateMode(): boolean {
|
||||||
return this.newOrUpdatedMeeting != null;
|
return this.newOrUpdatedMeetingInputs != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDefaultExpirationTime(): string {
|
getDefaultExpirationTime(): string {
|
||||||
@@ -324,13 +368,14 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
blankMeeting(): MeetingSetupInfo {
|
blankMeeting(): MeetingSetupInputs {
|
||||||
return {
|
return {
|
||||||
// no groupId yet
|
// no groupId yet
|
||||||
name: "",
|
name: "",
|
||||||
expiresAt: this.getDefaultExpirationTime(),
|
expiresAt: this.getDefaultExpirationTime(),
|
||||||
userFullName: this.fullName,
|
userFullName: this.fullName,
|
||||||
password: (this.currentMeeting?.password as string) || "",
|
password: (this.currentMeeting?.password as string) || "",
|
||||||
|
projectLink: (this.currentMeeting?.projectLink as string) || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,19 +387,20 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const queryPassword = this.$route.query["password"] as string;
|
||||||
if (response?.data?.data) {
|
if (response?.data?.data) {
|
||||||
this.currentMeeting = {
|
this.currentMeeting = {
|
||||||
...response.data.data,
|
...response.data.data,
|
||||||
userFullName: this.fullName,
|
userFullName: this.fullName,
|
||||||
password: this.currentMeeting?.password || "",
|
password: this.currentMeeting?.password || queryPassword || "",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// no meeting found
|
// no meeting found
|
||||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// no meeting found
|
// no meeting found
|
||||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,14 +408,14 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.newOrUpdatedMeeting) {
|
if (!this.newOrUpdatedMeetingInputs) {
|
||||||
throw Error(
|
throw Error(
|
||||||
"There was no meeting data to create. We should never get here.",
|
"There was no meeting data to create. We should never get here.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert local time to UTC for comparison and server submission
|
// Convert local time to UTC for comparison and server submission
|
||||||
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
|
const localExpiresAt = new Date(this.newOrUpdatedMeetingInputs.expiresAt);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (localExpiresAt <= now) {
|
if (localExpiresAt <= now) {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
@@ -383,7 +429,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.newOrUpdatedMeeting.userFullName) {
|
if (!this.newOrUpdatedMeetingInputs.userFullName) {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -395,7 +441,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.newOrUpdatedMeeting.password) {
|
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -408,35 +454,36 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// create content with user's name and DID encrypted with password
|
// create content with user's name & DID encrypted with password
|
||||||
const content = {
|
const content = {
|
||||||
name: this.newOrUpdatedMeeting.userFullName,
|
name: this.newOrUpdatedMeetingInputs.userFullName,
|
||||||
did: this.activeDid,
|
did: this.activeDid,
|
||||||
isRegistered: this.isRegistered,
|
isRegistered: this.isRegistered,
|
||||||
};
|
};
|
||||||
const encryptedContent = await encryptMessage(
|
const encryptedContent = await encryptMessage(
|
||||||
JSON.stringify(content),
|
JSON.stringify(content),
|
||||||
this.newOrUpdatedMeeting.password,
|
this.newOrUpdatedMeetingInputs.password,
|
||||||
);
|
);
|
||||||
|
|
||||||
const headers = await getHeaders(this.activeDid);
|
const headers = await getHeaders(this.activeDid);
|
||||||
const response = await this.axios.post(
|
const response = await this.axios.post(
|
||||||
this.apiServer + "/api/partner/groupOnboard",
|
this.apiServer + "/api/partner/groupOnboard",
|
||||||
{
|
{
|
||||||
name: this.newOrUpdatedMeeting.name,
|
name: this.newOrUpdatedMeetingInputs.name,
|
||||||
expiresAt: localExpiresAt.toISOString(),
|
expiresAt: localExpiresAt.toISOString(),
|
||||||
content: encryptedContent,
|
content: encryptedContent,
|
||||||
|
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
|
||||||
},
|
},
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data && response.data.success) {
|
if (response.data && response.data.success) {
|
||||||
this.currentMeeting = {
|
this.currentMeeting = {
|
||||||
...this.newOrUpdatedMeeting,
|
...this.newOrUpdatedMeetingInputs,
|
||||||
groupId: response.data.success.groupId,
|
groupId: response.data.success.groupId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.newOrUpdatedMeeting = null;
|
this.newOrUpdatedMeetingInputs = null;
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -502,7 +549,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.currentMeeting = null;
|
this.currentMeeting = null;
|
||||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||||
this.showDeleteConfirm = false;
|
this.showDeleteConfirm = false;
|
||||||
|
|
||||||
this.$notify(
|
this.$notify(
|
||||||
@@ -534,11 +581,12 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
// 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);
|
||||||
this.newOrUpdatedMeeting = {
|
this.newOrUpdatedMeetingInputs = {
|
||||||
name: this.currentMeeting.name,
|
name: this.currentMeeting.name,
|
||||||
expiresAt: this.formatDateForInput(localExpiresAt),
|
expiresAt: this.formatDateForInput(localExpiresAt),
|
||||||
userFullName: this.currentMeeting.userFullName || "",
|
userFullName: this.currentMeeting.userFullName || "",
|
||||||
password: this.currentMeeting.password || "",
|
password: this.currentMeeting.password || "",
|
||||||
|
projectLink: this.currentMeeting.projectLink || "",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -549,18 +597,18 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
|
|
||||||
cancelEditing() {
|
cancelEditing() {
|
||||||
// Reset form data
|
// Reset form data
|
||||||
this.newOrUpdatedMeeting = null;
|
this.newOrUpdatedMeetingInputs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMeeting() {
|
async updateMeeting() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
if (!this.newOrUpdatedMeeting) {
|
if (!this.newOrUpdatedMeetingInputs) {
|
||||||
throw Error("There was no meeting data to update.");
|
throw Error("There was no meeting data to update.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert local time to UTC for comparison and server submission
|
// Convert local time to UTC for comparison and server submission
|
||||||
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
|
const localExpiresAt = new Date(this.newOrUpdatedMeetingInputs.expiresAt);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (localExpiresAt <= now) {
|
if (localExpiresAt <= now) {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
@@ -574,7 +622,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.newOrUpdatedMeeting.userFullName) {
|
if (!this.newOrUpdatedMeetingInputs.userFullName) {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -586,7 +634,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.newOrUpdatedMeeting.password) {
|
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||||
this.$notify(
|
this.$notify(
|
||||||
{
|
{
|
||||||
group: "alert",
|
group: "alert",
|
||||||
@@ -598,15 +646,15 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// create content with user's name and DID encrypted with password
|
// create content with user's name & DID encrypted with password
|
||||||
const content = {
|
const content = {
|
||||||
name: this.newOrUpdatedMeeting.userFullName,
|
name: this.newOrUpdatedMeetingInputs.userFullName,
|
||||||
did: this.activeDid,
|
did: this.activeDid,
|
||||||
isRegistered: this.isRegistered,
|
isRegistered: this.isRegistered,
|
||||||
};
|
};
|
||||||
const encryptedContent = await encryptMessage(
|
const encryptedContent = await encryptMessage(
|
||||||
JSON.stringify(content),
|
JSON.stringify(content),
|
||||||
this.newOrUpdatedMeeting.password,
|
this.newOrUpdatedMeetingInputs.password,
|
||||||
);
|
);
|
||||||
|
|
||||||
const headers = await getHeaders(this.activeDid);
|
const headers = await getHeaders(this.activeDid);
|
||||||
@@ -614,9 +662,10 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.apiServer + "/api/partner/groupOnboard",
|
this.apiServer + "/api/partner/groupOnboard",
|
||||||
{
|
{
|
||||||
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting
|
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting
|
||||||
name: this.newOrUpdatedMeeting.name,
|
name: this.newOrUpdatedMeetingInputs.name,
|
||||||
expiresAt: localExpiresAt.toISOString(),
|
expiresAt: localExpiresAt.toISOString(),
|
||||||
content: encryptedContent,
|
content: encryptedContent,
|
||||||
|
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
|
||||||
},
|
},
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
@@ -624,10 +673,17 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
if (response.data && response.data.success) {
|
if (response.data && response.data.success) {
|
||||||
// Update the current meeting with only the necessary fields
|
// Update the current meeting with only the necessary fields
|
||||||
this.currentMeeting = {
|
this.currentMeeting = {
|
||||||
...this.newOrUpdatedMeeting,
|
...this.newOrUpdatedMeetingInputs,
|
||||||
groupId: (this.currentMeeting?.groupId as number) || -1,
|
groupId: (this.currentMeeting?.groupId as number) || -1,
|
||||||
};
|
};
|
||||||
this.newOrUpdatedMeeting = null;
|
this.newOrUpdatedMeetingInputs = null;
|
||||||
|
|
||||||
|
if (this.currentMeeting?.password) {
|
||||||
|
this.$router.push({
|
||||||
|
name: "onboard-meeting-setup",
|
||||||
|
query: { password: this.currentMeeting?.password },
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw { response: response };
|
throw { response: response };
|
||||||
}
|
}
|
||||||
@@ -673,5 +729,21 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
5000,
|
5000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copyMembersLinkToClipboard() {
|
||||||
|
useClipboard()
|
||||||
|
.copy(this.onboardMeetingMembersLink())
|
||||||
|
.then(() => {
|
||||||
|
this.$notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "info",
|
||||||
|
title: "Copied",
|
||||||
|
text: "The member link is copied to the clipboard.",
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ import { test, expect } from '@playwright/test';
|
|||||||
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
||||||
|
|
||||||
test('Record 9 new gifts', async ({ page }) => {
|
test('Record 9 new gifts', async ({ page }) => {
|
||||||
|
test.slow(); // Set timeout longer
|
||||||
|
|
||||||
const giftCount = 9;
|
const giftCount = 9;
|
||||||
const standardTitle = 'Gift ';
|
const standardTitle = 'Gift ';
|
||||||
const finalTitles = [];
|
const finalTitles = [];
|
||||||
@@ -127,6 +129,6 @@ test('Record 9 new gifts', async ({ page }) => {
|
|||||||
await expect(page.locator('ul#listLatestActivity li')
|
await expect(page.locator('ul#listLatestActivity li')
|
||||||
.filter({ hasText: finalTitles[i] })
|
.filter({ hasText: finalTitles[i] })
|
||||||
.first())
|
.first())
|
||||||
.toBeVisible({ timeout: 10000 });
|
.toBeVisible({ timeout: 3000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||