Compare commits

...

24 Commits

Author SHA1 Message Date
abc05d426e update messaging for unknown icons on home feed 2025-06-07 16:23:07 -06:00
Jose Olarte III
92b9c9334c Clickable person & project icons
- Known entities get routed to their corresponding detail views
- Unknown entities pop up a notification
2025-06-02 21:35:15 +08:00
Jose Olarte III
706182ca0c Icon for hidden DID entity
- Display EntityIcon for known entities, eye-slash icon for hidden entities, and the person-question icon for unknown entities
- Design tweaks (spacings, mostly)
2025-06-02 17:44:37 +08:00
Matthew Raymer
68e0fc4976 merge(master): big merge for qrcode-reboot 2025-06-02 03:57:00 +00:00
504056eb90 add some time to test 30 (but shrink the per-loop timeout) 2025-06-01 15:08:32 -06:00
5a1007c49c add iOS development team ID 2025-06-01 14:29:32 -06:00
Jose Olarte III
cbc14e21ec Look in .own.did for DID, as well 2025-05-30 17:34:50 +08:00
Jose Olarte III
3e02b3924a Look for DID in .iss field instead of .own.did 2025-05-28 19:08:15 +08:00
Jose Olarte III
8b03789941 Change heading based on crop flag 2025-05-28 16:32:41 +08:00
Jose Olarte III
b4a6b99301 Better error handling for image upload 2025-05-28 16:17:49 +08:00
Jose Olarte III
e839997f91 TEST: platform- and camera-specific mirroring 2025-05-27 18:58:35 +08:00
Jose Olarte III
d8d054a0e1 Streamlined QR scanner web camera
- No need to stop and start camera preview
2025-05-27 18:57:12 +08:00
Jose Olarte III
efc720e47f Mobile native to use web camera
- Ensure consistent UI experience for uploading photos across mobile web and native
2025-05-27 17:46:19 +08:00
Jose Olarte III
0a85bea533 Feature: context-based default camera
- Specify the default camera (front / back) to use
2025-05-27 15:37:45 +08:00
Jose Olarte III
47501ae917 Linting 2025-05-26 19:23:41 +08:00
Jose Olarte III
28634839ec Feature: front/back camera toggle
- Added to gifting and profile dialog camera for now. Toggle button is hidden in desktop.
- WIP: same feature for QR scanner camera.
- WIP: ability to specify default camera depending on where it's called.
2025-05-26 19:23:28 +08:00
1b7c96ed9b don't highlight profile Advanced link in blue 2025-05-13 19:37:47 -06:00
41365fab8f add projectLink to onboarding meeting, plus enhancements to setup usability 2025-05-13 19:36:23 -06:00
5cc42be58a fix some test scripts 2025-04-08 20:31:47 -06:00
3d1a2eeb8d adjust to app.timesafari.app in more places 2025-04-08 20:29:08 -06:00
7b0ee2e44e more ios folders to ignore (until we figure out the right way to dance with capacitor-assets) 2025-04-06 19:52:44 -06:00
ac018997e8 adjust instructions for capacitor-assets and more files 2025-04-06 19:48:45 -06:00
6f449e9c1f restore important file from previous cleanup 2025-04-06 19:15:00 -06:00
543599a6a1 remove icon files that are generated by capacitor-assets 2025-04-06 19:02:01 -06:00
62 changed files with 457 additions and 252 deletions

5
.gitignore vendored
View File

@@ -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

View File

@@ -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
```

View File

@@ -1,5 +1,5 @@
{
"appId": "app.timesafari",
"appId": "app.timesafari.app",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

13
ios/.gitignore vendored
View File

@@ -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

View File

@@ -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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -1,14 +0,0 @@
{
"images": [
{
"idiom": "universal",
"size": "1024x1024",
"filename": "AppIcon-512@2x.png",
"platform": "ios"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -165,7 +165,7 @@
},
"main": "./dist-electron/main.js",
"build": {
"appId": "app.timesafari",
"appId": "app.timesafari.app",
"productName": "TimeSafari",
"directories": {
"output": "dist-electron-packages"

View File

@@ -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();

View File

@@ -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`);

View File

@@ -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;

View File

@@ -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];
}

View File

@@ -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&hellip;</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();
}
}

View File

@@ -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,
);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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

View File

@@ -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.

View File

@@ -115,6 +115,7 @@
<ImageMethodDialog
ref="imageMethodDialog"
:is-registered="isRegistered"
default-camera-mode="user"
/>
</div>
<div class="mt-4">

View File

@@ -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 || "",

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -50,7 +50,7 @@
/>
</span>
</div>
<ImageMethodDialog ref="imageDialog" />
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<input
v-model="agentDid"

View File

@@ -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);
});
}
}

View File

@@ -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) ||

View File

@@ -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"
>
&bull; Open shortcut page for members
<font-awesome icon="external-link" />
</router-link>
<span>
&bull; 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>

View File

@@ -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 });
}
});