Compare commits
24 Commits
new-storag
...
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/
|
||||
|
||||
android/app/src/main/assets/public
|
||||
android/app/src/main/res
|
||||
# PWA icon files generated by capacitor-assets
|
||||
icons
|
||||
|
||||
|
||||
|
||||
@@ -343,7 +343,11 @@ Prerequisites: macOS with Xcode installed
|
||||
3. Copy the assets:
|
||||
|
||||
```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
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appId": "app.timesafari",
|
||||
"appId": "app.timesafari.app",
|
||||
"appName": "TimeSafari",
|
||||
"webDir": "dist",
|
||||
"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"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background>
|
||||
<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>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background>
|
||||
<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>
|
||||
|
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
|
||||
App/App/capacitor.config.json
|
||||
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;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -406,6 +407,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
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",
|
||||
"build": {
|
||||
"appId": "app.timesafari",
|
||||
"appId": "app.timesafari.app",
|
||||
"productName": "TimeSafari",
|
||||
"directories": {
|
||||
"output": "dist-electron-packages"
|
||||
|
||||
@@ -51,7 +51,7 @@ const { existsSync } = require('fs');
|
||||
*/
|
||||
function checkCommand(command, errorMessage) {
|
||||
try {
|
||||
execSync(command + ' --version', { stdio: 'ignore' });
|
||||
execSync(command, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`❌ ${errorMessage}`);
|
||||
@@ -164,10 +164,10 @@ function main() {
|
||||
|
||||
// Check required command line tools
|
||||
// These are essential for building and testing the application
|
||||
success &= checkCommand('node', 'Node.js is required');
|
||||
success &= checkCommand('npm', 'npm is required');
|
||||
success &= checkCommand('gradle', 'Gradle is required for Android builds');
|
||||
success &= checkCommand('xcodebuild', 'Xcode is required for iOS builds');
|
||||
success &= checkCommand('node --version', 'Node.js is required');
|
||||
success &= checkCommand('npm --version', 'npm is required');
|
||||
success &= checkCommand('gradle --version', 'Gradle is required for Android builds');
|
||||
success &= checkCommand('xcodebuild --help', 'Xcode is required for iOS builds');
|
||||
|
||||
// Check platform-specific development environments
|
||||
success &= checkAndroidSetup();
|
||||
|
||||
@@ -170,7 +170,7 @@ const executeDeeplink = async (url, description, log) => {
|
||||
|
||||
try {
|
||||
// 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
|
||||
|
||||
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"
|
||||
>
|
||||
<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
|
||||
:entity-id="record.issuerDid"
|
||||
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[2rem]"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
<font-awesome
|
||||
v-else-if="isHiddenDid(record.issuerDid)"
|
||||
icon="eye-slash"
|
||||
class="text-slate-400 !size-[2rem] cursor-pointer"
|
||||
@click="notifyHiddenPerson"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="person-circle-question"
|
||||
class="text-slate-400 !size-[2rem] cursor-pointer"
|
||||
@click="notifyUnknownPerson"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold">
|
||||
{{ record.issuer.known ? record.issuer.displayName : "" }}
|
||||
<h3
|
||||
v-if="record.issuer.known"
|
||||
class="font-semibold leading-tight"
|
||||
>
|
||||
{{ record.issuer.displayName }}
|
||||
</h3>
|
||||
<p class="ms-auto text-xs text-slate-500 italic">
|
||||
{{ friendlyDate }}
|
||||
@@ -46,7 +61,7 @@
|
||||
<!-- Record Image -->
|
||||
<div
|
||||
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});`"
|
||||
>
|
||||
<a
|
||||
@@ -63,33 +78,55 @@
|
||||
</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 -->
|
||||
<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>
|
||||
<!-- Project Icon -->
|
||||
<div v-if="record.providerPlanName">
|
||||
<ProjectIcon
|
||||
:entity-id="record.providerPlanName"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/project/' + encodeURIComponent(record.providerPlanHandleId || ''),
|
||||
}"
|
||||
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>
|
||||
<!-- Identicon for DIDs -->
|
||||
<div v-else-if="record.agentDid">
|
||||
<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
|
||||
v-if="!isHiddenDid(record.agentDid)"
|
||||
:to="{
|
||||
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>
|
||||
<!-- Unknown Person -->
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
@click="notifyUnknownPerson"
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||
/>
|
||||
@@ -110,9 +147,9 @@
|
||||
|
||||
<!-- Arrow -->
|
||||
<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 }}
|
||||
</div>
|
||||
|
||||
@@ -129,29 +166,51 @@
|
||||
|
||||
<!-- Destination -->
|
||||
<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>
|
||||
<!-- Project Icon -->
|
||||
<div v-if="record.recipientProjectName">
|
||||
<ProjectIcon
|
||||
:entity-id="record.recipientProjectName"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/project/' + encodeURIComponent(record.fulfillsPlanHandleId || ''),
|
||||
}"
|
||||
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>
|
||||
<!-- Identicon for DIDs -->
|
||||
<div v-else-if="record.recipientDid">
|
||||
<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
|
||||
v-if="!isHiddenDid(record.recipientDid)"
|
||||
:to="{
|
||||
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>
|
||||
<!-- Unknown Person -->
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
@click="notifyUnknownPerson"
|
||||
icon="person-circle-question"
|
||||
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 EntityIcon from "./EntityIcon.vue";
|
||||
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
||||
import { containsHiddenDid } from "../libs/endorserServer";
|
||||
import { containsHiddenDid, isHiddenDid } from "../libs/endorserServer";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
@@ -202,6 +262,33 @@ export default class ActivityListItem extends Vue {
|
||||
@Prop() activeDid!: 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()
|
||||
cacheImage(image: string) {
|
||||
return image;
|
||||
|
||||
@@ -136,7 +136,7 @@ export default class DataExportSection extends Vue {
|
||||
transform: (table, value, key) => {
|
||||
if (table === "contacts") {
|
||||
// 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) {
|
||||
delete value[prop];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="text-lg text-center font-bold relative">
|
||||
<h1 id="ViewHeading" class="text-center font-bold">
|
||||
<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>Add Photo</span>
|
||||
</h1>
|
||||
@@ -119,12 +119,21 @@
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<button
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
<div class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4">
|
||||
<button
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<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
|
||||
@@ -262,6 +271,11 @@ const inputImageFileNameRef = ref<Blob>();
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultCameraMode: {
|
||||
type: String,
|
||||
default: 'environment',
|
||||
validator: (value: string) => ['environment', 'user'].includes(value)
|
||||
}
|
||||
},
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
@@ -303,6 +317,9 @@ export default class ImageMethodDialog extends Vue {
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
/** Current camera facing mode */
|
||||
private currentFacingMode: 'environment' | 'user' = 'environment';
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
@@ -362,15 +379,16 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
|
||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||
logger.debug("ImageMethodDialog.open called");
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
this.imageCallback = setImageFn;
|
||||
this.visible = true;
|
||||
this.currentFacingMode = this.defaultCameraMode as 'environment' | 'user';
|
||||
|
||||
// Start camera preview immediately if not on mobile
|
||||
if (!this.platformCapabilities.isNativeApp) {
|
||||
this.startCameraPreview();
|
||||
}
|
||||
// Start camera preview immediately
|
||||
logger.debug("Starting camera preview from open()");
|
||||
this.startCameraPreview();
|
||||
}
|
||||
|
||||
async uploadImageFile(event: Event) {
|
||||
@@ -439,46 +457,21 @@ export default class ImageMethodDialog extends Vue {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
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 {
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Requesting camera access...";
|
||||
this.showCameraPreview = true;
|
||||
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({
|
||||
video: { facingMode: "environment" },
|
||||
video: { facingMode: this.currentFacingMode },
|
||||
});
|
||||
logger.debug("Camera access granted");
|
||||
this.cameraStream = stream;
|
||||
@@ -493,31 +486,41 @@ export default class ImageMethodDialog extends Vue {
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
videoElement.play().then(() => {
|
||||
logger.debug("Video element started playing");
|
||||
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) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
let errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to access camera";
|
||||
if (
|
||||
error instanceof Error && (
|
||||
error.name === "NotReadableError" ||
|
||||
error.name === "TrackStartError"
|
||||
) {
|
||||
)) {
|
||||
errorMessage =
|
||||
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
|
||||
} else if (
|
||||
error instanceof Error && (
|
||||
error.name === "NotAllowedError" ||
|
||||
error.name === "PermissionDeniedError"
|
||||
) {
|
||||
)) {
|
||||
errorMessage =
|
||||
"Camera access was denied. Please allow camera access in your browser settings.";
|
||||
}
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage = errorMessage;
|
||||
this.error = errorMessage;
|
||||
this.showCameraPreview = false;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -527,7 +530,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
},
|
||||
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 {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
@@ -613,6 +629,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
@@ -667,6 +684,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = undefined;
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ export default class MembersList extends Vue {
|
||||
this.decryptedMembers.length === 0 ||
|
||||
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 {
|
||||
// the first (organizer) member was decrypted OK
|
||||
return "";
|
||||
@@ -337,7 +337,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
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,
|
||||
);
|
||||
@@ -347,7 +347,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -90,7 +90,10 @@ db.on("populate", async () => {
|
||||
try {
|
||||
await db.settings.add(DEFAULT_SETTINGS);
|
||||
} 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
|
||||
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
|
||||
@@ -123,7 +126,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
||||
console.error(`Attempt ${i + 1}: Database open failed:`, error);
|
||||
if (i < retries - 1) {
|
||||
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 {
|
||||
throw error;
|
||||
}
|
||||
@@ -145,10 +148,16 @@ export async function updateDefaultSettings(
|
||||
await safeOpenDatabase();
|
||||
} catch (openError: unknown) {
|
||||
console.error("Failed to open database:", openError);
|
||||
const errorMessage = openError instanceof Error ? openError.message : String(openError);
|
||||
throw new Error(`Database connection failed: ${errorMessage}. Please try again or restart the app.`);
|
||||
const errorMessage =
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error("Error updating default settings:", error);
|
||||
|
||||
@@ -549,11 +549,13 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
||||
mnemonic: mnemonic,
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
|
||||
await updateDefaultSettings({ activeDid: newId.did });
|
||||
} catch (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 });
|
||||
return newId.did;
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface PlatformCapabilities {
|
||||
hasFileDownload: boolean;
|
||||
/** Whether the platform requires special file handling instructions */
|
||||
needsFileHandlingInstructions: boolean;
|
||||
/** Whether the platform is a native app (Capacitor, Electron, etc.) */
|
||||
isNativeApp: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +94,12 @@ export interface PlatformService {
|
||||
*/
|
||||
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.
|
||||
* @param url - The deep link URL to handle
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
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 { logger } from "../../utils/logger";
|
||||
|
||||
@@ -16,6 +16,9 @@ import { logger } from "../../utils/logger";
|
||||
* - Platform-specific features
|
||||
*/
|
||||
export class CapacitorPlatformService implements PlatformService {
|
||||
/** Current camera direction */
|
||||
private currentDirection: CameraDirection = 'BACK';
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the Capacitor platform
|
||||
* @returns Platform capabilities object
|
||||
@@ -28,6 +31,7 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
hasFileDownload: false,
|
||||
needsFileHandlingInstructions: true,
|
||||
isNativeApp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,6 +405,7 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
allowEditing: true,
|
||||
resultType: CameraResultType.Base64,
|
||||
source: CameraSource.Camera,
|
||||
direction: this.currentDirection,
|
||||
});
|
||||
|
||||
const blob = await this.processImageData(image.base64String);
|
||||
@@ -466,6 +471,15 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
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.
|
||||
* Note: Capacitor handles deep links automatically.
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
<ImageMethodDialog
|
||||
ref="imageMethodDialog"
|
||||
:is-registered="isRegistered"
|
||||
default-camera-mode="user"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
||||
@@ -357,7 +357,8 @@ export default class ContactQRScan extends Vue {
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
if (!contactInfo.did) {
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
@@ -370,7 +371,7 @@ export default class ContactQRScan extends Vue {
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: contactInfo.did,
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
|
||||
@@ -152,30 +152,6 @@
|
||||
@camera-on="onCameraOn"
|
||||
@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>
|
||||
</section>
|
||||
@@ -258,6 +234,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Add property to track if we're on desktop
|
||||
private isDesktop = false;
|
||||
private isFrontCamera = false;
|
||||
|
||||
async created() {
|
||||
try {
|
||||
@@ -506,7 +483,8 @@ export default class ContactQRScanShow extends Vue {
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
if (!contactInfo.did) {
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
@@ -519,7 +497,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: contactInfo.did,
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
@@ -723,14 +701,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
document.addEventListener("resume", this.handleAppResume);
|
||||
// Start scanning automatically when view is loaded
|
||||
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() {
|
||||
@@ -877,6 +847,8 @@ export default class ContactQRScanShow extends Vue {
|
||||
onCameraOn(): void {
|
||||
this.cameraState = "active";
|
||||
this.isInitializing = false;
|
||||
this.isFrontCamera = this.preferredCamera === "user";
|
||||
this.applyCameraMirroring();
|
||||
}
|
||||
|
||||
onCameraOff(): void {
|
||||
@@ -922,6 +894,8 @@ export default class ContactQRScanShow extends Vue {
|
||||
toggleCamera(): void {
|
||||
this.preferredCamera =
|
||||
this.preferredCamera === "user" ? "environment" : "user";
|
||||
this.isFrontCamera = this.preferredCamera === "user";
|
||||
this.applyCameraMirroring();
|
||||
}
|
||||
|
||||
private handleError(error: unknown): void {
|
||||
@@ -943,17 +917,21 @@ export default class ContactQRScanShow extends Vue {
|
||||
// Add method to detect desktop browser
|
||||
private detectDesktopBrowser(): boolean {
|
||||
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
|
||||
get shouldMirrorCamera(): boolean {
|
||||
// On desktop, always mirror the webcam
|
||||
if (this.isDesktop) {
|
||||
return true;
|
||||
// Add method to apply camera mirroring
|
||||
private applyCameraMirroring(): void {
|
||||
const videoElement = document.querySelector(
|
||||
".qr-scanner video",
|
||||
) 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>
|
||||
@@ -968,8 +946,9 @@ export default class ContactQRScanShow extends Vue {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Remove the default mirroring from CSS since we're handling it in JavaScript */
|
||||
:deep(.qr-scanner video) {
|
||||
transform: scaleX(-1);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Ensure the canvas for QR detection is not mirrored */
|
||||
|
||||
@@ -54,17 +54,12 @@
|
||||
/>
|
||||
</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
|
||||
icon="chair"
|
||||
class="fa-fw text-2xl"
|
||||
@click="
|
||||
warning(
|
||||
'You must get registered before you can initiate an onboarding meeting.',
|
||||
'Not Registered',
|
||||
)
|
||||
"
|
||||
@click="this.$router.push({ name: 'onboard-meeting-list' })"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||
|
||||
<div class="mt-4 flex justify-between gap-2">
|
||||
<!-- First Column for Giver -->
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||
|
||||
<input
|
||||
v-model="agentDid"
|
||||
|
||||
@@ -34,9 +34,13 @@
|
||||
</div>
|
||||
<div v-else-if="hitError">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -85,7 +89,7 @@ export default class NewIdentifierView extends Vue {
|
||||
.catch((error) => {
|
||||
this.loading = false;
|
||||
this.hitError = true;
|
||||
console.error('Failed to generate identity:', error);
|
||||
console.error("Failed to generate identity:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@
|
||||
|
||||
<!-- Members List -->
|
||||
<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>
|
||||
|
||||
<UserNameDialog
|
||||
@@ -69,6 +79,7 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
firstName = "";
|
||||
isRegistered = false;
|
||||
isLoading = true;
|
||||
projectLink = "";
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
$router!: Router;
|
||||
|
||||
@@ -85,10 +96,12 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
async created() {
|
||||
if (!this.groupId) {
|
||||
this.errorMessage = "The group info is missing. Go back and try again.";
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
if (!this.password) {
|
||||
this.errorMessage = "The password is missing. Go back and try again.";
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
@@ -129,6 +142,15 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
// 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) {
|
||||
this.errorMessage =
|
||||
serverMessageForUser(error) ||
|
||||
|
||||
@@ -49,12 +49,13 @@
|
||||
|
||||
<div v-if="currentMeeting.password" class="mt-4">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="text-red-600">
|
||||
Your copy of the password is not saved. Edit the meeting, or delete it
|
||||
and create a new meeting.
|
||||
You must reenter your password. Edit this meeting, or delete it and
|
||||
create a new meeting.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +93,7 @@
|
||||
v-if="
|
||||
!isLoading &&
|
||||
isInEditOrCreateMode() &&
|
||||
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
|
||||
newOrUpdatedMeetingInputs != null /* duplicate check is for typechecks */
|
||||
"
|
||||
class="mt-8"
|
||||
>
|
||||
@@ -115,7 +116,7 @@
|
||||
>
|
||||
<input
|
||||
id="meetingName"
|
||||
v-model="newOrUpdatedMeeting.name"
|
||||
v-model="newOrUpdatedMeetingInputs.name"
|
||||
type="text"
|
||||
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"
|
||||
@@ -131,7 +132,7 @@
|
||||
>
|
||||
<input
|
||||
id="expirationTime"
|
||||
v-model="newOrUpdatedMeeting.expiresAt"
|
||||
v-model="newOrUpdatedMeetingInputs.expiresAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
:min="minDateTime"
|
||||
@@ -145,7 +146,7 @@
|
||||
>
|
||||
<input
|
||||
id="password"
|
||||
v-model="newOrUpdatedMeeting.password"
|
||||
v-model="newOrUpdatedMeetingInputs.password"
|
||||
type="text"
|
||||
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"
|
||||
@@ -159,7 +160,7 @@
|
||||
>
|
||||
<input
|
||||
id="userName"
|
||||
v-model="newOrUpdatedMeeting.userFullName"
|
||||
v-model="newOrUpdatedMeetingInputs.userFullName"
|
||||
type="text"
|
||||
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"
|
||||
@@ -167,6 +168,19 @@
|
||||
/>
|
||||
</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
|
||||
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"
|
||||
@@ -201,15 +215,25 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl">Meeting Members</h2>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="!!currentMeeting.password"
|
||||
:to="onboardMeetingMembersLink()"
|
||||
class="inline-block text-blue-600"
|
||||
target="_blank"
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer text-blue-600"
|
||||
@click="copyMembersLinkToClipboard"
|
||||
title="Click to copy link for members"
|
||||
>
|
||||
• Open shortcut page for members
|
||||
<font-awesome icon="external-link" />
|
||||
</router-link>
|
||||
<span>
|
||||
• Page for Members
|
||||
<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
|
||||
:password="currentMeeting.password || ''"
|
||||
@@ -219,6 +243,21 @@
|
||||
/>
|
||||
</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 class="flex justify-center items-center h-full">
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||
@@ -229,6 +268,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import MembersList from "../components/MembersList.vue";
|
||||
@@ -240,19 +281,22 @@ import {
|
||||
} from "../libs/endorserServer";
|
||||
import { encryptMessage } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface ServerMeeting {
|
||||
groupId: number; // from the server
|
||||
name: string; // from the server
|
||||
expiresAt: string; // from the server
|
||||
name: string; // to & from the server
|
||||
expiresAt: string; // to & from the server
|
||||
userFullName?: 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;
|
||||
expiresAt: string;
|
||||
userFullName: string;
|
||||
password: string;
|
||||
projectLink: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -269,7 +313,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
) => void;
|
||||
|
||||
currentMeeting: ServerMeeting | null = null;
|
||||
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
|
||||
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
isDeleting = false;
|
||||
@@ -295,11 +339,11 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
|
||||
isInCreateMode(): boolean {
|
||||
return this.newOrUpdatedMeeting != null && this.currentMeeting == null;
|
||||
return this.newOrUpdatedMeetingInputs != null && this.currentMeeting == null;
|
||||
}
|
||||
|
||||
isInEditOrCreateMode(): boolean {
|
||||
return this.newOrUpdatedMeeting != null;
|
||||
return this.newOrUpdatedMeetingInputs != null;
|
||||
}
|
||||
|
||||
getDefaultExpirationTime(): string {
|
||||
@@ -324,13 +368,14 @@ export default class OnboardMeetingView extends Vue {
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
blankMeeting(): MeetingSetupInfo {
|
||||
blankMeeting(): MeetingSetupInputs {
|
||||
return {
|
||||
// no groupId yet
|
||||
name: "",
|
||||
expiresAt: this.getDefaultExpirationTime(),
|
||||
userFullName: this.fullName,
|
||||
password: (this.currentMeeting?.password as string) || "",
|
||||
projectLink: (this.currentMeeting?.projectLink as string) || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,19 +387,20 @@ export default class OnboardMeetingView extends Vue {
|
||||
{ headers },
|
||||
);
|
||||
|
||||
const queryPassword = this.$route.query["password"] as string;
|
||||
if (response?.data?.data) {
|
||||
this.currentMeeting = {
|
||||
...response.data.data,
|
||||
userFullName: this.fullName,
|
||||
password: this.currentMeeting?.password || "",
|
||||
password: this.currentMeeting?.password || queryPassword || "",
|
||||
};
|
||||
} else {
|
||||
// no meeting found
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
}
|
||||
} catch (error) {
|
||||
// no meeting found
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,14 +408,14 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
if (!this.newOrUpdatedMeeting) {
|
||||
if (!this.newOrUpdatedMeetingInputs) {
|
||||
throw Error(
|
||||
"There was no meeting data to create. We should never get here.",
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (localExpiresAt <= now) {
|
||||
this.$notify(
|
||||
@@ -383,7 +429,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeeting.userFullName) {
|
||||
if (!this.newOrUpdatedMeetingInputs.userFullName) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -395,7 +441,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeeting.password) {
|
||||
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -408,35 +454,36 @@ export default class OnboardMeetingView extends Vue {
|
||||
return;
|
||||
}
|
||||
|
||||
// create content with user's name and DID encrypted with password
|
||||
// create content with user's name & DID encrypted with password
|
||||
const content = {
|
||||
name: this.newOrUpdatedMeeting.userFullName,
|
||||
name: this.newOrUpdatedMeetingInputs.userFullName,
|
||||
did: this.activeDid,
|
||||
isRegistered: this.isRegistered,
|
||||
};
|
||||
const encryptedContent = await encryptMessage(
|
||||
JSON.stringify(content),
|
||||
this.newOrUpdatedMeeting.password,
|
||||
this.newOrUpdatedMeetingInputs.password,
|
||||
);
|
||||
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
const response = await this.axios.post(
|
||||
this.apiServer + "/api/partner/groupOnboard",
|
||||
{
|
||||
name: this.newOrUpdatedMeeting.name,
|
||||
name: this.newOrUpdatedMeetingInputs.name,
|
||||
expiresAt: localExpiresAt.toISOString(),
|
||||
content: encryptedContent,
|
||||
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
this.currentMeeting = {
|
||||
...this.newOrUpdatedMeeting,
|
||||
...this.newOrUpdatedMeetingInputs,
|
||||
groupId: response.data.success.groupId,
|
||||
};
|
||||
|
||||
this.newOrUpdatedMeeting = null;
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -502,7 +549,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
});
|
||||
|
||||
this.currentMeeting = null;
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
this.showDeleteConfirm = false;
|
||||
|
||||
this.$notify(
|
||||
@@ -534,11 +581,12 @@ export default class OnboardMeetingView extends Vue {
|
||||
// Populate form with existing meeting data
|
||||
if (this.currentMeeting) {
|
||||
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||
this.newOrUpdatedMeeting = {
|
||||
this.newOrUpdatedMeetingInputs = {
|
||||
name: this.currentMeeting.name,
|
||||
expiresAt: this.formatDateForInput(localExpiresAt),
|
||||
userFullName: this.currentMeeting.userFullName || "",
|
||||
password: this.currentMeeting.password || "",
|
||||
projectLink: this.currentMeeting.projectLink || "",
|
||||
};
|
||||
} else {
|
||||
logger.error(
|
||||
@@ -549,18 +597,18 @@ export default class OnboardMeetingView extends Vue {
|
||||
|
||||
cancelEditing() {
|
||||
// Reset form data
|
||||
this.newOrUpdatedMeeting = null;
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
}
|
||||
|
||||
async updateMeeting() {
|
||||
this.isLoading = true;
|
||||
if (!this.newOrUpdatedMeeting) {
|
||||
if (!this.newOrUpdatedMeetingInputs) {
|
||||
throw Error("There was no meeting data to update.");
|
||||
}
|
||||
|
||||
try {
|
||||
// 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();
|
||||
if (localExpiresAt <= now) {
|
||||
this.$notify(
|
||||
@@ -574,7 +622,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeeting.userFullName) {
|
||||
if (!this.newOrUpdatedMeetingInputs.userFullName) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -586,7 +634,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeeting.password) {
|
||||
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -598,15 +646,15 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// create content with user's name and DID encrypted with password
|
||||
// create content with user's name & DID encrypted with password
|
||||
const content = {
|
||||
name: this.newOrUpdatedMeeting.userFullName,
|
||||
name: this.newOrUpdatedMeetingInputs.userFullName,
|
||||
did: this.activeDid,
|
||||
isRegistered: this.isRegistered,
|
||||
};
|
||||
const encryptedContent = await encryptMessage(
|
||||
JSON.stringify(content),
|
||||
this.newOrUpdatedMeeting.password,
|
||||
this.newOrUpdatedMeetingInputs.password,
|
||||
);
|
||||
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
@@ -614,9 +662,10 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.apiServer + "/api/partner/groupOnboard",
|
||||
{
|
||||
// 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(),
|
||||
content: encryptedContent,
|
||||
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
@@ -624,10 +673,17 @@ export default class OnboardMeetingView extends Vue {
|
||||
if (response.data && response.data.success) {
|
||||
// Update the current meeting with only the necessary fields
|
||||
this.currentMeeting = {
|
||||
...this.newOrUpdatedMeeting,
|
||||
...this.newOrUpdatedMeetingInputs,
|
||||
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 {
|
||||
throw { response: response };
|
||||
}
|
||||
@@ -673,5 +729,21 @@ export default class OnboardMeetingView extends Vue {
|
||||
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>
|
||||
|
||||
@@ -88,6 +88,8 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
||||
|
||||
test('Record 9 new gifts', async ({ page }) => {
|
||||
test.slow(); // Set timeout longer
|
||||
|
||||
const giftCount = 9;
|
||||
const standardTitle = 'Gift ';
|
||||
const finalTitles = [];
|
||||
@@ -127,6 +129,6 @@ test('Record 9 new gifts', async ({ page }) => {
|
||||
await expect(page.locator('ul#listLatestActivity li')
|
||||
.filter({ hasText: finalTitles[i] })
|
||||
.first())
|
||||
.toBeVisible({ timeout: 10000 });
|
||||
.toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||