Merge branch 'deep_linking'

This commit is contained in:
Matthew Raymer
2025-03-17 03:01:53 +00:00
226 changed files with 16524 additions and 4598 deletions

View File

@@ -40,7 +40,10 @@
<div
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100"
>
<fa icon="circle-info" class="fa-fw fa-xl"></fa>
<font-awesome
icon="circle-info"
class="fa-fw fa-xl"
></font-awesome>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
@@ -48,10 +51,10 @@
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
@click="close(notification.id)"
>
<fa icon="xmark" class="fa-fw"></fa>
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button>
</div>
</div>
@@ -63,7 +66,10 @@
<div
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100"
>
<fa icon="circle-info" class="fa-fw fa-xl"></fa>
<font-awesome
icon="circle-info"
class="fa-fw fa-xl"
></font-awesome>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
@@ -71,10 +77,10 @@
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
@click="close(notification.id)"
>
<fa icon="xmark" class="fa-fw"></fa>
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button>
</div>
</div>
@@ -86,7 +92,10 @@
<div
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100"
>
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa>
<font-awesome
icon="triangle-exclamation"
class="fa-fw fa-xl"
></font-awesome>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
@@ -94,10 +103,10 @@
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
@click="close(notification.id)"
>
<fa icon="xmark" class="fa-fw"></fa>
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button>
</div>
</div>
@@ -109,7 +118,10 @@
<div
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100"
>
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa>
<font-awesome
icon="triangle-exclamation"
class="fa-fw fa-xl"
></font-awesome>
</div>
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
@@ -117,10 +129,10 @@
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
<button
@click="close(notification.id)"
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
@click="close(notification.id)"
>
<fa icon="xmark" class="fa-fw"></fa>
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button>
</div>
</div>
@@ -131,7 +143,8 @@
<!--
This "group" of "modal" is the prompt for an answer.
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
Set "type" as follows: "confirm" for yes/no, and "notification" ones:
"-permission", "-mute", "-off"
-->
<NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
@@ -174,11 +187,11 @@
<button
v-if="notification.onYes"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@click="
notification.onYes();
close(notification.id);
"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
>
Yes{{
notification.yesText ? ", " + notification.yesText : ""
@@ -187,12 +200,12 @@
<button
v-if="notification.onNo"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
@click="
notification.onNo(stopAsking);
close(notification.id);
stopAsking = false; // reset value
"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
>
No{{ notification.noText ? ", " + notification.noText : "" }}
</button>
@@ -209,8 +222,8 @@
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="stopAsking"
type="checkbox"
name="stopAsking"
class="sr-only"
/>
@@ -224,6 +237,7 @@
</label>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="
notification.onCancel
? notification.onCancel(stopAsking)
@@ -231,7 +245,6 @@
close(notification.id);
stopAsking = false; // reset value for next time they open this modal
"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
>
{{ notification.onYes ? "Cancel" : "Close" }}
</button>
@@ -270,8 +283,8 @@
Until I turn it back on
</button>
<button
@click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="close(notification.id)"
>
Cancel
</button>
@@ -292,17 +305,17 @@
</p>
<button
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
@click="
close(notification.id);
turnOffNotifications(notification);
"
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
>
Turn Off Notification
</button>
<button
@click="close(notification.id)"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="close(notification.id)"
>
Leave it On
</button>
@@ -315,12 +328,11 @@
</NotificationGroup>
</template>
<style></style>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
import { NotificationIface } from "./constants/app";
import { logger } from "./utils/logger";
interface Settings {
notifyingNewActivityTime?: string;
@@ -334,38 +346,38 @@ export default class App extends Vue {
stopAsking = false;
// created() {
// console.log(
// logger.log(
// "Component created: Reactivity set up.",
// window.location.pathname,
// );
// }
// beforeCreate() {
// console.log("Component beforeCreate: Instance initialized.");
// logger.log("Component beforeCreate: Instance initialized.");
// }
// beforeMount() {
// console.log("Component beforeMount: Template is about to be rendered.");
// logger.log("Component beforeMount: Template is about to be rendered.");
// }
// mounted() {
// console.log("Component mounted: Template is now rendered.");
// logger.log("Component mounted: Template is now rendered.");
// }
// beforeUpdate() {
// console.log("Component beforeUpdate: DOM is about to be updated.");
// logger.log("Component beforeUpdate: DOM is about to be updated.");
// }
// updated() {
// console.log("Component updated: DOM has been updated.");
// logger.log("Component updated: DOM has been updated.");
// }
// beforeUnmount() {
// console.log("Component beforeUnmount: Cleaning up before removal.");
// logger.log("Component beforeUnmount: Cleaning up before removal.");
// }
// unmounted() {
// console.log("Component unmounted: Component removed from the DOM.");
// logger.log("Component unmounted: Component removed from the DOM.");
// }
truncateLongWords(sentence: string) {
@@ -378,42 +390,42 @@ export default class App extends Vue {
async turnOffNotifications(
notification: NotificationIface,
): Promise<boolean> {
console.log("Starting turnOffNotifications...");
logger.log("Starting turnOffNotifications...");
let subscription: PushSubscriptionJSON | null = null;
let allGoingOff = false;
try {
console.log("Retrieving settings for the active account...");
logger.log("Retrieving settings for the active account...");
const settings: Settings = await retrieveSettingsForActiveAccount();
console.log("Retrieved settings:", settings);
logger.log("Retrieved settings:", settings);
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
const notifyingReminder = !!settings?.notifyingReminderTime;
if (!notifyingNewActivity || !notifyingReminder) {
allGoingOff = true;
console.log("Both notifications are being turned off.");
logger.log("Both notifications are being turned off.");
}
console.log("Checking service worker readiness...");
logger.log("Checking service worker readiness...");
await navigator.serviceWorker?.ready
.then((registration) => {
console.log("Service worker is ready. Fetching subscription...");
logger.log("Service worker is ready. Fetching subscription...");
return registration.pushManager.getSubscription();
})
.then(async (subscript: PushSubscription | null) => {
if (subscript) {
subscription = subscript.toJSON();
console.log("PushSubscription retrieved:", subscription);
logger.log("PushSubscription retrieved:", subscription);
if (allGoingOff) {
console.log("Unsubscribing from push notifications...");
logger.log("Unsubscribing from push notifications...");
await subscript.unsubscribe();
console.log("Successfully unsubscribed.");
logger.log("Successfully unsubscribed.");
}
} else {
logConsoleAndDb("Subscription object is not available.");
console.log("No subscription found.");
logger.log("No subscription found.");
}
})
.catch((error) => {
@@ -422,11 +434,11 @@ export default class App extends Vue {
JSON.stringify(error),
true,
);
console.error("Error during subscription fetch:", error);
logger.error("Error during subscription fetch:", error);
});
if (!subscription) {
console.log("No subscription available. Notifying user...");
logger.log("No subscription available. Notifying user...");
this.$notify(
{
group: "alert",
@@ -436,7 +448,7 @@ export default class App extends Vue {
},
5000,
);
console.log("Exiting as there is no subscription to process.");
logger.log("Exiting as there is no subscription to process.");
return true;
}
@@ -445,12 +457,12 @@ export default class App extends Vue {
};
if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title;
console.log(
logger.log(
`Server subscription updated with notifyType: ${notification.title}`,
);
}
console.log("Sending unsubscribe request to the server...");
logger.log("Sending unsubscribe request to the server...");
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
method: "POST",
headers: {
@@ -465,9 +477,9 @@ export default class App extends Vue {
`Push server failed: ${response.status} ${errorBody}`,
true,
);
console.error("Push server error response:", errorBody);
logger.error("Push server error response:", errorBody);
}
console.log(`Server response status: ${response.status}`);
logger.log(`Server response status: ${response.status}`);
return response.ok;
})
.catch((error) => {
@@ -475,14 +487,14 @@ export default class App extends Vue {
"Push server communication failed: " + JSON.stringify(error),
true,
);
console.error("Error during server communication:", error);
logger.error("Error during server communication:", error);
return false;
});
const message = pushServerSuccess
? "Notification is off."
: "Notification is still on. Try to turn it off again.";
console.log("Server response processed. Message:", message);
logger.log("Server response processed. Message:", message);
this.$notify(
{
@@ -495,11 +507,11 @@ export default class App extends Vue {
);
if (notification.callback) {
console.log("Executing notification callback...");
logger.log("Executing notification callback...");
notification.callback(pushServerSuccess);
}
console.log(
logger.log(
"Completed turnOffNotifications with success:",
pushServerSuccess,
);
@@ -509,7 +521,7 @@ export default class App extends Vue {
"Error turning off notifications: " + JSON.stringify(error),
true,
);
console.error("Critical error in turnOffNotifications:", error);
logger.error("Critical error in turnOffNotifications:", error);
this.$notify(
{
@@ -526,3 +538,5 @@ export default class App extends Vue {
}
}
</script>
<style></style>

View File

@@ -29,29 +29,29 @@
<p class="text-sm mb-2">{{ text }}</p>
<button
@click="handleOption1(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
@click="handleOption1(close)"
>
{{ option1Text }}
</button>
<button
@click="handleOption2(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-700 text-white px-2 py-2 rounded-md mb-2"
@click="handleOption2(close)"
>
{{ option2Text }}
</button>
<button
@click="handleOption3(close)"
class="block w-full text-center text-md font-bold capitalize bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@click="handleOption3(close)"
>
{{ option3Text }}
</button>
<button
@click="handleCancel(close)"
class="block w-full text-center text-md font-bold capitalize bg-slate-600 text-white px-2 py-2 rounded-md"
@click="handleCancel(close)"
>
Cancel
</button>

View File

@@ -6,10 +6,10 @@
{{ message }}
Note that their name is only stored on this device.
<input
v-model="newText"
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="newText"
/>
<div class="mt-8">

View File

@@ -1,5 +1,6 @@
<template>
<div v-html="generateIcon()" class="w-fit"></div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="w-fit" v-html="generateIcon()"></div>
</template>
<script lang="ts">
import { createAvatar, StyleOptions } from "@dicebear/core";

View File

@@ -16,8 +16,8 @@
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="hasVisibleDid"
type="checkbox"
name="toggleFilterFromMyContacts"
class="sr-only"
/>
@@ -46,8 +46,8 @@
<div v-if="hasSearchBox" class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="isNearby"
type="checkbox"
name="toggleFilterNearby"
class="sr-only"
/>
@@ -98,7 +98,7 @@ import {
LRectangle,
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
@@ -111,6 +111,7 @@ import { db, retrieveSettingsForActiveAccount } from "../db/index";
},
})
export default class FeedFilters extends Vue {
$router!: Router;
onCloseIfChanged = () => {};
hasSearchBox = false;
hasVisibleDid = false;

View File

@@ -5,10 +5,10 @@
{{ customTitle }}
</h1>
<input
v-model="description"
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
:placeholder="prompt || 'What was given?'"
v-model="description"
/>
<div class="flex flex-row justify-center">
<span
@@ -21,19 +21,19 @@
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
<font-awesome icon="chevron-left" />
</div>
<input
id="inputGivenAmount"
v-model="amountInput"
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
<font-awesome icon="chevron-right" />
</div>
</div>
<div class="mt-4 flex justify-center">
@@ -62,7 +62,7 @@
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
<font-awesome
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
@@ -162,7 +162,7 @@ export default class GiftedDialog extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
logger.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
@@ -311,7 +311,7 @@ export default class GiftedDialog extends Vue {
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.error("Error with give creation result:", result);
logger.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
@@ -337,7 +337,7 @@ export default class GiftedDialog extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
logger.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
serverMessageForUser(error) ||

View File

@@ -7,7 +7,7 @@
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
@click="cancel"
>
<fa icon="xmark" class="w-[1em]"></fa>
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div>
</h1>
<span class="mt-2 flex justify-between">
@@ -16,7 +16,7 @@
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
@click="prevIdea()"
>
<fa icon="chevron-left" class="m-auto" />
<font-awesome icon="chevron-left" class="m-auto" />
</span>
<div class="m-2">
@@ -45,7 +45,7 @@
class="text-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-2 rounded-md mt-4"
@click="nextIdeaPastContacts()"
>
Skip Contacts <fa icon="forward" />
Skip Contacts <font-awesome icon="forward" />
</button>
</span>
</span>
@@ -57,7 +57,7 @@
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2 flex"
@click="nextIdea()"
>
<fa icon="chevron-right" class="m-auto" />
<font-awesome icon="chevron-right" class="m-auto" />
</span>
</span>
<button
@@ -82,6 +82,7 @@ import { GiverReceiverInputInfo } from "../libs/util";
@Component
export default class GivenPrompts extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
CATEGORY_CONTACTS = 1;
CATEGORY_IDEAS = 0;
@@ -145,7 +146,7 @@ export default class GivenPrompts extends Vue {
// proceed with logic but don't change values (just in case some actions are added later)
this.visible = false;
if (this.currentCategory === this.CATEGORY_IDEAS) {
(this.$router as Router).push({
this.$router.push({
name: "contact-gift",
query: {
prompt: this.IDEAS[this.currentIdeaIndex],

View File

@@ -7,8 +7,8 @@
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold capitalize">{{ roleName }} Details</h2>
<button @click="close" class="text-gray-500 hover:text-gray-700">
<fa icon="times" />
<button class="text-gray-500 hover:text-gray-700" @click="close">
<font-awesome icon="times" />
</button>
</div>
@@ -53,7 +53,10 @@
target="_blank"
class="text-blue-500"
>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</span>
</span>
@@ -66,7 +69,7 @@
<div class="mt-4">
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
<a class="text-blue-500" @click="onClickShareClaim()"
>click here to share the information with them and ask if they'll
tell you more about the {{ roleName }}.</a
>
@@ -74,8 +77,8 @@
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
>click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a
>
@@ -86,8 +89,8 @@
<!-- Footer -->
<div class="flex justify-end">
<button
@click="close"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
@click="close"
>
Close
</button>

View File

@@ -12,14 +12,14 @@
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<fa icon="xmark" class="w-[1em]"></fa>
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div>
</div>
<div>
<div class="text-center mt-8">
<div>
<fa
<font-awesome
icon="camera"
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 px-2 py-2 rounded-md"
@click="openPhotoDialog()"
@@ -31,17 +31,21 @@
<div class="mt-4">
<span class="mt-2">
... or paste a URL:
<input type="text" v-model="imageUrl" class="border-2" />
<input v-model="imageUrl" type="text" class="border-2" />
</span>
<span class="ml-2">
<fa
<font-awesome
v-if="imageUrl"
icon="check"
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 px-2 py-2 rounded-md cursor-pointer"
@click="acceptUrl"
/>
<!-- so that there's no shifting when it becomes visible -->
<fa v-else icon="check" class="text-white bg-white px-2 py-2" />
<font-awesome
v-else
icon="check"
class="text-white bg-white px-2 py-2"
/>
</span>
</div>
</div>

View File

@@ -8,7 +8,7 @@
class="text-white text-2xl p-2 rounded-full hover:bg-white/10"
@click="close"
>
<fa icon="xmark" />
<font-awesome icon="xmark" />
</button>
<!-- Mobile share button -->
@@ -17,7 +17,7 @@
class="text-white text-xl p-2 rounded-full hover:bg-white/10"
@click="handleShare"
>
<fa icon="ellipsis" />
<font-awesome icon="ellipsis" />
</button>
</div>
@@ -27,8 +27,8 @@
<img
:src="imageUrl"
class="max-h-[calc(100vh-5rem)] w-full h-full object-contain"
@click.stop
alt="expanded shared content"
@click.stop
/>
</div>
</div>
@@ -74,7 +74,7 @@ export default class ImageViewer extends Vue {
}
}
} catch (error) {
console.warn("Share failed, opening in new tab:", error);
logger.warn("Share failed, opening in new tab:", error);
window.open(this.imageUrl, "_blank");
}
}

View File

@@ -8,18 +8,18 @@
If you want to store your own way, the invitation ID is:
{{ inviteIdentifier }}
<input
v-model="text"
type="text"
placeholder="Notes"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="text"
/>
<!-- Add date selection element -->
Expiration
<input
v-model="expiresAt"
type="date"
class="block rounded border border-slate-400 mb-4 px-3 py-2"
v-model="expiresAt"
/>
<div class="mt-8">

View File

@@ -5,7 +5,7 @@
v-if="isLoading"
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
>
<fa icon="spinner" class="fa-spin-pulse" />
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<!-- Members List -->
@@ -33,13 +33,13 @@
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="plus" class="text-sm" />
<font-awesome icon="plus" class="text-sm" />
</span>
/
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="minus" class="text-sm" />
<font-awesome icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</span>
@@ -54,20 +54,23 @@
<span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
>
<fa icon="circle-user" class="text-xl" />
<font-awesome icon="circle-user" class="text-xl" />
</span>
to add them to your contacts.
</span>
</div>
<div class="flex justify-center">
<!-- always have at least one refresh button even without members in case the organizer changes the password -->
<!--
always have at least one refresh button even without members in case the organizer
changes the password
-->
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
title="Refresh members list"
@click="fetchMembers"
>
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
</button>
</div>
<div
@@ -83,24 +86,24 @@
class="flex justify-end"
>
<button
@click="addAsContact(member)"
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800 transition-colors"
title="Add as contact"
@click="addAsContact(member)"
>
<fa icon="circle-user" class="text-xl" />
<font-awesome icon="circle-user" class="text-xl" />
</button>
</div>
<button
v-if="member.did !== activeDid"
class="ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Contact info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
class="ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Contact info"
>
<fa icon="circle-info" class="text-base" />
<font-awesome icon="circle-info" class="text-base" />
</button>
</div>
<div class="flex">
@@ -111,23 +114,23 @@
class="flex items-center"
>
<button
@click="checkWhetherContactBeforeAdmitting(member)"
class="mr-2 w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
:title="
member.member.admitted ? 'Remove member' : 'Admit member'
"
@click="checkWhetherContactBeforeAdmitting(member)"
>
<fa
<font-awesome
:icon="member.member.admitted ? 'minus' : 'plus'"
class="text-sm"
/>
</button>
<button
@click="informAboutAdmission()"
class="mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 transition-colors"
title="Admission info"
@click="informAboutAdmission()"
>
<fa icon="circle-info" class="text-base" />
<font-awesome icon="circle-info" class="text-base" />
</button>
</span>
</div>
@@ -138,11 +141,11 @@
</div>
<div v-if="membersToShow().length > 0" class="flex justify-center mt-4">
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
title="Refresh members list"
@click="fetchMembers"
>
<fa icon="rotate" :class="{ 'fa-spin': isLoading }" />
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
</button>
</div>

View File

@@ -3,11 +3,11 @@
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
<input
v-model="description"
type="text"
data-testId="inputDescription"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Description of what is offered"
v-model="description"
/>
<div class="flex flex-row mt-2">
<span
@@ -17,23 +17,23 @@
{{ libsUtil.UNIT_SHORT[amountUnitCode] }}
</span>
<div
v-if="amountInput !== '0'"
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="decrement()"
v-if="amountInput !== '0'"
>
<fa icon="chevron-left" />
<font-awesome icon="chevron-left" />
</div>
<input
v-model="amountInput"
data-testId="inputOfferAmount"
type="number"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
<font-awesome icon="chevron-right" />
</div>
</div>
<div class="mt-4 flex justify-center">
@@ -89,6 +89,7 @@ import {
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { logger } from "../utils/logger";
@Component
export default class OfferDialog extends Vue {
@@ -121,7 +122,7 @@ export default class OfferDialog extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
logger.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
@@ -249,7 +250,7 @@ export default class OfferDialog extends Vue {
this.isOfferCreationError(result.response)
) {
const errorMessage = this.getOfferCreationErrorMessage(result);
console.error("Error with offer creation result:", result);
logger.error("Error with offer creation result:", result);
this.$notify(
{
group: "alert",
@@ -272,7 +273,7 @@ export default class OfferDialog extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with offer recordation caught:", error);
logger.error("Error with offer recordation caught:", error);
const message =
error.userMessage ||
error.response?.data?.error?.message ||

View File

@@ -10,7 +10,7 @@
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]" />
<font-awesome icon="xmark" class="w-[1em]" />
</div>
</h1>
@@ -21,7 +21,7 @@
</span>
click on the
<span class="bg-green-600 text-white rounded-full">
<fa icon="plus" class="fa-fw" />
<font-awesome icon="plus" class="fa-fw" />
</span>
button to express your appreciation for... whatever -- maybe thanks for
showing you all these fascinating stories of
@@ -40,7 +40,7 @@
<p class="mt-4 flex items-center">
The
<fa
<font-awesome
icon="house-chimney"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
@@ -84,7 +84,7 @@
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]" />
<font-awesome icon="xmark" class="w-[1em]" />
</div>
</h1>
@@ -106,7 +106,7 @@
<p class="mt-4 flex items-center">
The
<fa
<font-awesome
icon="magnifying-glass"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
@@ -141,14 +141,14 @@
class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)"
>
<fa icon="xmark" class="w-[1em]" />
<font-awesome icon="xmark" class="w-[1em]" />
</div>
</h1>
<p class="relative">
Now you can take a turn: click on the
<span class="bg-green-600 text-white rounded-full">
<fa icon="plus" class="fa-fw" />
<font-awesome icon="plus" class="fa-fw" />
</span>
button to throw out projects of your own... anything you'd like to see
happen. If your first idea doesn't catch anyone, try, try again... and
@@ -157,7 +157,7 @@
<p class="mt-4 flex items-center">
The
<fa
<font-awesome
icon="hand"
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded"
/>
@@ -168,7 +168,7 @@
By the way, one good way to get to know your neighbors and their
interests is to offer time directly to them. You can do this on the
contacts screen
<fa icon="users" class="text-slate-500" />
<font-awesome icon="users" class="text-slate-500" />
which is a great way to get to know a neighbor's interests.
</p>
@@ -219,6 +219,7 @@ import { OnboardPage } from "../libs/util";
})
export default class OnboardingDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
activeDid = "";
firstContactName = null;
@@ -254,7 +255,7 @@ export default class OnboardingDialog extends Vue {
finishedOnboarding: true,
});
if (goHome) {
(this.$router as Router).push({ name: "home" });
this.$router.push({ name: "home" });
}
}
}

View File

@@ -15,12 +15,12 @@
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()"
>
<fa icon="xmark" class="w-[1em]"></fa>
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div>
</div>
<div v-if="uploading" class="flex justify-center">
<fa
<font-awesome
icon="spinner"
class="fa-spin fa-3x text-center block px-12 py-12"
/>
@@ -28,7 +28,7 @@
<div v-else-if="blob">
<div v-if="crop">
<VuePictureCropper
:boxStyle="{
:box-style="{
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
@@ -56,8 +56,8 @@
</div>
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
<button
@click="uploadImage"
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 py-1 px-2 rounded-md"
@click="uploadImage"
>
<span>Upload</span>
</button>
@@ -67,8 +67,8 @@
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
>
<button
@click="retryImage"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
@click="retryImage"
>
<span>Retry</span>
</button>
@@ -81,37 +81,37 @@
:resolution="{ width: 375, height: 812 }"
-->
<camera
facingMode="environment"
autoplay
ref="camera"
facing-mode="environment"
autoplay
@started="cameraStarted()"
>
<div
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
>
<button
@click="takeImage()"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="takeImage()"
>
<fa icon="camera" class="w-[1em]"></fa>
<font-awesome icon="camera" class="w-[1em]"></font-awesome>
</button>
</div>
<div
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
>
<button
@click="swapMirrorClass()"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="swapMirrorClass()"
>
<fa icon="left-right" class="w-[1em]"></fa>
<font-awesome icon="left-right" class="w-[1em]"></font-awesome>
</button>
</div>
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
<button
@click="switchCamera()"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="switchCamera()"
>
<fa icon="rotate" class="w-[1em]"></fa>
<font-awesome icon="rotate" class="w-[1em]"></font-awesome>
</button>
</div>
</camera>
@@ -129,6 +129,7 @@ import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
@Component({ components: { Camera, VuePictureCropper } })
export default class PhotoDialog extends Vue {
@@ -155,7 +156,7 @@ export default class PhotoDialog extends Vue {
this.activeDid = settings.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
logger.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
@@ -373,7 +374,7 @@ export default class PhotoDialog extends Vue {
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
console.log(
logger.log(
"Using shared image API server, so only users on that server can play with images.",
);
}
@@ -387,7 +388,7 @@ export default class PhotoDialog extends Vue {
this.close();
this.setImageCallback(response.data.url as string);
} catch (error) {
console.error("Error uploading the image", error);
logger.error("Error uploading the image", error);
this.$notify(
{
group: "alert",

View File

@@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<a
v-if="linkToFull && imageUrl"
@@ -5,12 +6,12 @@
target="_blank"
class="h-full w-full object-contain"
>
<div v-html="generateIdenticon()" class="h-full w-full object-contain" />
<div class="h-full w-full object-contain" v-html="generateIdenticon()" />
</a>
<div
v-else
v-html="generateIdenticon()"
class="h-full w-full object-contain"
v-html="generateIdenticon()"
/>
</template>
<script lang="ts">

View File

@@ -25,7 +25,7 @@
</p>
<p v-else class="text-lg mb-4">
Waiting for system initialization, which may take up to 5 seconds...
<fa icon="spinner" spin />
<font-awesome icon="spinner" spin />
</p>
<div v-if="serviceWorkerReady && vapidKey">
@@ -54,23 +54,25 @@
<span class="flex flex-row justify-center">
<span class="mt-2">... at: </span>
<input
type="number"
@change="checkHourInput"
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
v-model="hourInput"
type="number"
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
@change="checkHourInput"
/>
<input
type="number"
@change="checkMinuteInput"
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20"
v-model="minuteInput"
type="number"
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20"
@change="checkMinuteInput"
/>
<span
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
@click="hourAm = !hourAm"
>
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
<span v-else> PM <fa icon="chevron-up" /> </span>
<span v-if="hourAm">
AM <font-awesome icon="chevron-down" />
</span>
<span v-else> PM <font-awesome icon="chevron-up" /> </span>
</span>
</span>
</div>
@@ -86,8 +88,8 @@
</div>
<button
@click="close()"
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
@click="close()"
>
No, Not Now
</button>
@@ -109,6 +111,7 @@ import {
import { MASTER_SECRET_KEY } from "../db/tables/secret";
import { urlBase64ToUint8Array } from "../libs/crypto/vc/util";
import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger";
// Example interface for error
interface ErrorResponse {
@@ -493,13 +496,13 @@ export default class PushNotificationPermission extends Vue {
return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) {
const errorMsg = "Push messaging is not supported";
console.warn(errorMsg);
logger.warn(errorMsg);
return reject(new Error(errorMsg));
}
if (window.Notification.permission !== "granted") {
const errorMsg = "Notification permission not granted";
console.warn(errorMsg);
logger.warn(errorMsg);
return reject(new Error(errorMsg));
}
@@ -560,7 +563,7 @@ export default class PushNotificationPermission extends Vue {
body: JSON.stringify(subscription),
}).then((response) => {
if (!response.ok) {
console.error("Bad response subscribing to web push: ", response);
logger.error("Bad response subscribing to web push: ", response);
throw new Error("Failed to send push subscription to server");
}
logConsoleAndDb("Push subscription sent to server successfully.");

View File

@@ -13,7 +13,7 @@
>
<router-link :to="{ name: 'home' }" class="block text-center py-2 px-1">
<div class="flex flex-col items-center">
<fa icon="house-chimney" class="fa-fw" />
<font-awesome icon="house-chimney" class="fa-fw" />
<span class="text-xs mt-1">feed</span>
</div>
</router-link>
@@ -32,7 +32,7 @@
class="block text-center py-2 px-1"
>
<div class="flex flex-col items-center">
<fa icon="magnifying-glass" class="fa-fw" />
<font-awesome icon="magnifying-glass" class="fa-fw" />
<span class="text-xs mt-1">search</span>
</div>
</router-link>
@@ -51,7 +51,7 @@
class="block text-center py-2 px-1"
>
<div class="flex flex-col items-center">
<fa icon="hand" class="fa-fw" />
<font-awesome icon="hand" class="fa-fw" />
<span class="text-xs mt-1">your work</span>
</div>
</router-link>
@@ -70,7 +70,7 @@
class="block text-center py-2 px-1"
>
<div class="flex flex-col items-center">
<fa icon="users" class="fa-fw" />
<font-awesome icon="users" class="fa-fw" />
<span class="text-xs mt-1">contacts</span>
</div>
</router-link>
@@ -89,12 +89,13 @@
class="block text-center py-2 px-1"
>
<div class="flex flex-col items-center">
<fa icon="circle-user" class="fa-fw" />
<font-awesome icon="circle-user" class="fa-fw" />
<!--
We used to say "account", so we'll keep that in the code,
but it isn't accurate because we don't hold anything for them.
We'll say "profile" to the users.
(Or: settings, face, registry, cache, repo, vault... or separate preferences from identity.)
(Or: settings, face, registry, cache, repo, vault... or separate
preferences from identity.)
-->
<span class="text-xs mt-1">profile</span>
</div>

View File

@@ -6,10 +6,10 @@
{{ sharingExplanation }}
<input
v-model="givenName"
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="givenName"
/>
<div class="mt-8">

View File

@@ -5,6 +5,7 @@ import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js";
import { retrieveSettingsForActiveAccount } from "../../../../db";
import { getHeaders } from "../../../../libs/endorserServer";
import { logger } from "../../../../utils/logger";
const ANIMATION_DURATION_SECS = 10;
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
@@ -82,7 +83,7 @@ export async function loadLandmarks(vue, world, scene, loop) {
},
undefined,
function (error) {
console.error(error);
logger.error(error);
},
);
@@ -117,7 +118,7 @@ export async function loadLandmarks(vue, world, scene, loop) {
world.lights = [...world.lights, light];
}
} else {
console.error(
logger.error(
"Got bad server response status & data of",
resp.status,
resp.data,
@@ -128,7 +129,7 @@ export async function loadLandmarks(vue, world, scene, loop) {
);
}
} catch (error) {
console.error("Got exception contacting server:", error);
logger.error("Got exception contacting server:", error);
vue.setAlert(
"Error With Server",
"There was a problem retrieving your claims from the server.",

View File

@@ -13,6 +13,7 @@ import {
} from "./tables/settings";
import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app";
import { logger } from "../utils/logger";
// Define types for tables that hold sensitive and non-sensitive data
type SecretTable = { secret: Table<Secret> };
@@ -219,9 +220,9 @@ export async function logConsoleAndDb(
isError = false,
): Promise<void> {
if (isError) {
console.error(`${new Date().toISOString()} ${message}`);
logger.error(`${new Date().toISOString()} ${message}`);
} else {
console.log(`${new Date().toISOString()} ${message}`);
logger.log(`${new Date().toISOString()} ${message}`);
}
await db.open();

View File

@@ -20,7 +20,7 @@ export type Settings = {
// active Decentralized ID
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry
apiServer: string; // API server URL
apiServer?: string; // API server URL
filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden

View File

@@ -1,6 +1,7 @@
const { app, BrowserWindow } = require("electron");
const path = require("path");
const fs = require("fs");
const logger = require("../utils/logger");
// Check if running in dev mode
const isDev = process.argv.includes("--inspect");
@@ -8,8 +9,8 @@ const isDev = process.argv.includes("--inspect");
function createWindow() {
// Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js");
console.log("Checking preload path:", preloadPath);
console.log("Preload exists:", fs.existsSync(preloadPath));
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
// Create the browser window.
const mainWindow = new BrowserWindow({
@@ -65,25 +66,25 @@ function createWindow() {
if (isDev) {
// Debug info
console.log("Debug Info:");
console.log("Running in dev mode:", isDev);
console.log("App is packaged:", app.isPackaged);
console.log("Process resource path:", process.resourcesPath);
console.log("App path:", app.getAppPath());
console.log("__dirname:", __dirname);
console.log("process.cwd():", process.cwd());
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
const indexPath = path.join(__dirname, "www", "index.html");
if (isDev) {
console.log("Loading index from:", indexPath);
console.log("www path:", path.join(__dirname, "www"));
console.log("www assets path:", path.join(__dirname, "www", "assets"));
logger.log("Loading index from:", indexPath);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
}
if (!fs.existsSync(indexPath)) {
console.error(`Index file not found at: ${indexPath}`);
logger.error(`Index file not found at: ${indexPath}`);
throw new Error("Index file not found");
}
@@ -110,38 +111,38 @@ function createWindow() {
mainWindow
.loadFile(indexPath)
.then(() => {
console.log("Successfully loaded index.html");
logger.log("Successfully loaded index.html");
if (isDev) {
mainWindow.webContents.openDevTools();
console.log("DevTools opened - running in dev mode");
logger.log("DevTools opened - running in dev mode");
}
})
.catch((err) => {
console.error("Failed to load index.html:", err);
console.error("Attempted path:", indexPath);
logger.error("Failed to load index.html:", err);
logger.error("Attempted path:", indexPath);
});
// Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, level, message) => {
console.log("Renderer Console:", message);
logger.log("Renderer Console:", message);
});
// Add right after creating the BrowserWindow
mainWindow.webContents.on(
"did-fail-load",
(event, errorCode, errorDescription) => {
console.error("Page failed to load:", errorCode, errorDescription);
logger.error("Page failed to load:", errorCode, errorDescription);
},
);
mainWindow.webContents.on("preload-error", (event, preloadPath, error) => {
console.error("Preload script error:", preloadPath, error);
logger.error("Preload script error:", preloadPath, error);
});
mainWindow.webContents.on(
"console-message",
(event, level, message, line, sourceId) => {
console.log("Renderer Console:", line, sourceId, message);
logger.log("Renderer Console:", line, sourceId, message);
},
);
@@ -169,5 +170,5 @@ app.on("activate", () => {
// Handle any errors
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
logger.error("Uncaught Exception:", error);
});

View File

@@ -1,5 +1,27 @@
const { contextBridge, ipcRenderer } = require("electron");
const logger = {
log: (message, ...args) => {
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.log(message, ...args);
/* eslint-enable no-console */
}
},
warn: (message, ...args) => {
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.warn(message, ...args);
/* eslint-enable no-console */
}
},
error: (message, ...args) => {
/* eslint-disable no-console */
console.error(message, ...args); // Errors should always be logged
/* eslint-enable no-console */
},
};
// Use a more direct path resolution approach
const getPath = (pathType) => {
switch (pathType) {
@@ -19,7 +41,7 @@ const getPath = (pathType) => {
}
};
console.log("Preload script starting...");
logger.log("Preload script starting...");
try {
contextBridge.exposeInMainWorld("electronAPI", {
@@ -50,7 +72,7 @@ try {
},
});
console.log("Preload script completed successfully");
logger.log("Preload script completed successfully");
} catch (error) {
console.error("Error in preload script:", error);
logger.error("Error in preload script:", error);
}

View File

@@ -0,0 +1,58 @@
import { AxiosResponse } from "axios";
import { GiverReceiverInputInfo } from "../libs/util";
import { ErrorResult, ResultWithType } from "./common";
export interface GiverOutputInfo {
action: string;
giver?: GiverReceiverInputInfo;
description?: string;
amount?: number;
unitCode?: string;
}
export interface ClaimResult {
success: { claimId: string; handleId: string };
error: { code: string; message: string };
}
export interface VerifiableCredential {
exp?: number;
iat: number;
iss: string;
vc: {
"@context": string[];
type: string[];
credentialSubject: VerifiableCredentialSubject;
};
}
export interface VerifiableCredentialSubject {
"@context": string;
"@type": string;
[key: string]: unknown;
}
export interface WorldProperties {
startTime?: string;
endTime?: string;
}
export interface ProviderInfo {
/**
* Could be a DID or a handleId that identifies the provider
*/
identifier: string;
/**
* Indicates if the provider link has been confirmed
*/
linkConfirmed: boolean;
}
// Type for createAndSubmitClaim result
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
// Update SuccessResult to use ClaimResult
export interface SuccessResult extends ResultWithType {
type: "success";
response: AxiosResponse<ClaimResult>;
}

68
src/interfaces/claims.ts Normal file
View File

@@ -0,0 +1,68 @@
import { GenericVerifiableCredential } from "./common";
export interface AgreeVerifiableCredential {
"@context": string;
"@type": string;
object: Record<string, unknown>;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string;
"@type": "GiveAction";
agent?: { identifier: string };
description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string };
provider?: GenericVerifiableCredential;
recipient?: { identifier: string };
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string;
"@type": "Offer";
description?: string;
includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: {
description?: string;
isPartOf?: {
identifier?: string;
lastClaimId?: string;
"@type"?: string;
name?: string;
};
};
offeredBy?: { identifier: string };
recipient?: { identifier: string };
validThrough?: string;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
"@context": "https://schema.org";
"@type": "PlanAction";
name: string;
agent?: { identifier: string };
description?: string;
identifier?: string;
lastClaimId?: string;
location?: {
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
};
}
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
"@context": string;
"@type": "RegisterAction";
agent: { identifier: string };
identifier?: string;
object: string;
participant?: { identifier: string };
}

36
src/interfaces/common.ts Normal file
View File

@@ -0,0 +1,36 @@
// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential {
"@context"?: string;
"@type": string;
[key: string]: unknown;
}
export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
claim: T;
claimType?: string;
handleId: string;
id: string;
issuedAt: string;
issuer: string;
publicUrls?: Record<string, string>;
}
export interface ResultWithType {
type: string;
}
export interface ErrorResponse {
error?: {
message?: string;
};
}
export interface InternalError {
error: string;
userMessage?: string;
}
export interface ErrorResult extends ResultWithType {
type: "error";
error: InternalError;
}

View File

@@ -0,0 +1,13 @@
/**
* @file Deep Link Interface Definitions
* @author Matthew Raymer
*
* Defines the core interfaces for the deep linking system.
* These interfaces are used across the deep linking implementation
* to ensure type safety and consistent error handling.
*/
export interface DeepLinkError extends Error {
code: string;
details?: unknown;
}

7
src/interfaces/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from "./claims";
export * from "./claims-result";
export * from "./common";
export * from "./limits";
export * from "./records";
export * from "./user";
export * from "./deepLinks";

14
src/interfaces/limits.ts Normal file
View File

@@ -0,0 +1,14 @@
export interface EndorserRateLimits {
doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string;
maxRegistrationsPerMonth: string;
nextMonthBeginDateTime: string;
nextWeekBeginDateTime: string;
}
export interface ImageRateLimits {
doneImagesThisWeek: string;
maxImagesPerWeek: string;
nextWeekBeginDateTime: string;
}

93
src/interfaces/records.ts Normal file
View File

@@ -0,0 +1,93 @@
import { GiveVerifiableCredential, OfferVerifiableCredential } from "./claims";
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
[x: string]: PropertyKey | undefined | GiveVerifiableCredential;
type?: string;
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;
fulfillsType?: string;
handleId: string;
issuedAt: string;
issuerDid: string;
jwtId: string;
providerPlanHandleId?: string;
recipientDid: string;
unit: string;
}
// a summary record; the VC is found the fullClaim field
export interface OfferSummaryRecord {
amount: number;
amountGiven: number;
amountGivenConfirmed: number;
fullClaim: OfferVerifiableCredential;
fulfillsPlanHandleId: string;
handleId: string;
issuerDid: string;
jwtId: string;
nonAmountGivenConfirmed: number;
objectDescription: string;
offeredByDid: string;
recipientDid: string;
requirementsMet: boolean;
unit: string;
validThrough: string;
}
export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string;
}
// a summary record; the VC is not currently part of this record
export interface PlanSummaryRecord {
agentDid?: string;
description: string;
endTime?: string;
fulfillsPlanHandleId: string;
handleId: string;
image?: string;
issuerDid: string;
locLat?: number;
locLon?: number;
name?: string;
startTime?: string;
url?: string;
jwtId?: string;
}
/**
* Represents data about a project
*
* @deprecated
* (Maybe we should use PlanSummaryRecord instead, either by adding rowId or by iterating with jwtId.)
**/
export interface PlanData {
/**
* Description of the project
**/
description: string;
/**
* URL referencing information about the project
**/
handleId: string;
image?: string;
/**
* The DID of the issuer
*/
issuerDid: string;
/**
* Name of the project
**/
name: string;
/**
* The identifier of the project record -- different from jwtId
* (Maybe we should use the jwtId to iterate through the records instead.)
**/
rowId?: string;
}

8
src/interfaces/user.ts Normal file
View File

@@ -0,0 +1,8 @@
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}

59
src/lib/capacitor/app.ts Normal file
View File

@@ -0,0 +1,59 @@
// Import from node_modules using relative path
import {
App as CapacitorApp,
AppLaunchUrl,
BackButtonListener,
} from "../../../node_modules/@capacitor/app";
import type { PluginListenerHandle } from "@capacitor/core";
/**
* Interface defining the app event listener functionality
* Supports 'backButton' and 'appUrlOpen' events from Capacitor
*/
interface AppInterface {
/**
* Add listener for back button events
* @param eventName - Must be 'backButton'
* @param listenerFunc - Callback function for back button events
* @returns Promise that resolves with a removable listener handle
*/
addListener(
eventName: "backButton",
listenerFunc: BackButtonListener,
): Promise<PluginListenerHandle> & PluginListenerHandle;
/**
* Add listener for app URL open events
* @param eventName - Must be 'appUrlOpen'
* @param listenerFunc - Callback function for URL open events
* @returns Promise that resolves with a removable listener handle
*/
addListener(
eventName: "appUrlOpen",
listenerFunc: (data: AppLaunchUrl) => void,
): Promise<PluginListenerHandle> & PluginListenerHandle;
}
/**
* App wrapper for Capacitor functionality
* Provides type-safe event listeners for back button and URL open events
*/
export const App: AppInterface = {
addListener(
eventName: "backButton" | "appUrlOpen",
listenerFunc: BackButtonListener | ((data: AppLaunchUrl) => void),
): Promise<PluginListenerHandle> & PluginListenerHandle {
if (eventName === "backButton") {
return CapacitorApp.addListener(
eventName,
listenerFunc as BackButtonListener,
) as Promise<PluginListenerHandle> & PluginListenerHandle;
} else {
return CapacitorApp.addListener(
eventName,
listenerFunc as (data: AppLaunchUrl) => void,
) as Promise<PluginListenerHandle> & PluginListenerHandle;
}
},
};

168
src/lib/fontawesome.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* @file Font Awesome Icon Library Configuration
* @description Centralizes Font Awesome icon imports and library configuration
* @author Matthew Raymer
*/
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleUser,
faClock,
faCoins,
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQuestion,
faQrcode,
faRightFromBracket,
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faTrashCan,
faTriangleExclamation,
faUser,
faUsers,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
// Initialize Font Awesome library with all required icons
library.add(
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleUser,
faClock,
faCoins,
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQrcode,
faQuestion,
faRotate,
faRightFromBracket,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faTrashCan,
faTriangleExclamation,
faUser,
faUsers,
faXmark,
);
// Export the FontAwesomeIcon component for use in other files
export { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";

View File

@@ -11,6 +11,7 @@ import {
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
} from "../../libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { logger } from "../../utils/logger";
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
@@ -269,35 +270,35 @@ export async function testEncryptionDecryption() {
const testMessage = "Hello, this is a test message! 🚀";
const testPassword = "myTestPassword123";
console.log("Original message:", testMessage);
logger.log("Original message:", testMessage);
// Test encryption
console.log("Encrypting...");
logger.log("Encrypting...");
const encrypted = await encryptMessage(testMessage, testPassword);
console.log("Encrypted result:", encrypted);
logger.log("Encrypted result:", encrypted);
// Test decryption
console.log("Decrypting...");
logger.log("Decrypting...");
const decrypted = await decryptMessage(encrypted, testPassword);
console.log("Decrypted result:", decrypted);
logger.log("Decrypted result:", decrypted);
// Verify
const success = testMessage === decrypted;
console.log("Test " + (success ? "PASSED ✅" : "FAILED ❌"));
console.log("Messages match:", success);
logger.log("Test " + (success ? "PASSED ✅" : "FAILED ❌"));
logger.log("Messages match:", success);
// Test with wrong password
console.log("\nTesting with wrong password...");
logger.log("\nTesting with wrong password...");
try {
await decryptMessage(encrypted, "wrongPassword");
console.log("Should not reach here");
logger.log("Should not reach here");
} catch (error) {
console.log("Correctly failed with wrong password ✅");
logger.log("Correctly failed with wrong password ✅");
}
return success;
} catch (error) {
console.error("Test failed with error:", error);
logger.error("Test failed with error:", error);
return false;
}
}

View File

@@ -27,6 +27,7 @@ import {
peerDidToPublicKeyBytes,
verifyPeerSignature,
} from "../../../libs/crypto/vc/didPeer";
import { logger } from "../../../utils/logger";
export interface JWK {
kty: string;
@@ -69,7 +70,7 @@ export async function registerCredential(passkeyName?: string) {
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
if (attResp.rawId !== credIdBase64Url) {
console.log("Warning! The raw ID does not match the credential ID.");
logger.warn("Warning! The raw ID does not match the credential ID.");
}
const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url),

View File

@@ -1,4 +1,22 @@
import { Axios, AxiosRequestConfig, AxiosResponse } from "axios";
/**
* @fileoverview Endorser Server Interface and Utilities
* @author Matthew Raymer
*
* This module provides the interface and utilities for interacting with the Endorser server.
* It handles authentication, data validation, and server communication for claims, contacts,
* and other core functionality.
*
* Key Features:
* - Deep link URL path constants
* - DID validation and handling
* - Contact management utilities
* - Server authentication
* - Plan caching
*
* @module endorserServer
*/
import { Axios, AxiosRequestConfig } from "axios";
import { Buffer } from "buffer";
import { sha256 } from "ethereum-cryptography/sha256";
import { LRUCache } from "lru-cache";
@@ -17,63 +35,63 @@ import {
retrieveAccountMetadata,
retrieveFullyDecryptedAccount,
getPasskeyExpirationSeconds,
GiverReceiverInputInfo,
} from "../libs/util";
import { createEndorserJwtForKey, KeyMeta } from "../libs/crypto/vc";
import {
GiveVerifiableCredential,
OfferVerifiableCredential,
RegisterVerifiableCredential,
GenericVerifiableCredential,
GenericCredWrapper,
PlanSummaryRecord,
UserInfo,
CreateAndSubmitClaimResult,
} from "../interfaces";
import { logger } from "../utils/logger";
/**
* Standard context for schema.org data
* @constant {string}
*/
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims
/**
* Service identifier for RegisterAction claims
* @constant {string}
*/
export const SERVICE_ID = "endorser.ch";
// the header line for contacts exported via Endorser Mobile
/**
* Header line format for contacts exported via Endorser Mobile
* @constant {string}
*/
export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered";
// the suffix for the contact URL in this app where they are confirmed before import
/**
* URL path suffix for contact confirmation before import
* @constant {string}
*/
export const CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact-import/";
// the suffix for the contact URL in this app where a single one gets imported automatically
/**
* URL path suffix for the contact URL in this app where a single one gets imported automatically
* @constant {string}
*/
export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = "/contacts?contactJwt=";
// the suffix for the old contact URL -- deprecated Jan 2025, though "endorser.ch/contact?jwt=" shows data on endorser.ch server
/**
* URL path suffix for the old contact URL -- deprecated Jan 2025, though "endorser.ch/contact?jwt=" shows data on endorser.ch server
* @constant {string}
*/
export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=";
// unused now that we match on the URL path; just note that it was used for a while to create URLs that showed at endorser.ch
//export const CONTACT_URL_PREFIX_ENDORSER_CH_OLD = "https://endorser.ch";
// the prefix for handle IDs, the permanent ID for claims on Endorser
/**
* The prefix for handle IDs, the permanent ID for claims on Endorser
* @constant {string}
*/
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
export interface AgreeVerifiableCredential {
"@context": string;
"@type": string;
// "any" because arbitrary objects can be subject of agreement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: Record<string, any>;
}
export interface GiverOutputInfo {
action: string;
giver?: GiverReceiverInputInfo;
description?: string;
amount?: number;
unitCode?: string;
}
export interface ClaimResult {
success: { claimId: string; handleId: string };
error: { code: string; message: string };
}
// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree
"@type": string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
claim: T;
claimType?: string;
handleId: string;
id: string;
issuedAt: string;
issuer: string;
publicUrls?: Record<string, string>; // only for IDs that want to be public
}
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{
claim: { "@type": "" },
@@ -83,266 +101,98 @@ export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCr
issuer: "",
};
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;
fulfillsType?: string;
handleId: string;
issuedAt: string;
issuerDid: string;
jwtId: string;
providerPlanHandleId?: string;
recipientDid: string;
unit: string;
}
// a summary record; the VC is found the fullClaim field
export interface OfferSummaryRecord {
amount: number;
amountGiven: number;
amountGivenConfirmed: number;
fullClaim: OfferVerifiableCredential;
fulfillsPlanHandleId: string;
handleId: string;
issuerDid: string;
jwtId: string;
nonAmountGivenConfirmed: number;
objectDescription: string;
offeredByDid: string;
recipientDid: string;
requirementsMet: boolean;
unit: string;
validThrough: string;
}
export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string;
}
// a summary record; the VC is not currently part of this record
export interface PlanSummaryRecord {
agentDid?: string; // optional, if the issuer wants someone else to manage as well
description: string;
endTime?: string;
fulfillsPlanHandleId: string;
handleId: string;
image?: string;
issuerDid: string;
locLat?: number;
locLon?: number;
name?: string;
startTime?: string;
url?: string;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree
"@type": "GiveAction";
agent?: { identifier: string };
description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string };
provider?: GenericVerifiableCredential; // typically @type & identifier
recipient?: { identifier: string };
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; // optional when embedded... though it doesn't make sense to agree to an offer
"@type": "Offer";
description?: string; // conditions for the offer
includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: {
description?: string; // description of the item
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
};
offeredBy?: { identifier: string };
recipient?: { identifier: string };
validThrough?: string;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
"@context": "https://schema.org";
"@type": "PlanAction";
name: string;
agent?: { identifier: string };
description?: string;
identifier?: string;
lastClaimId?: string;
location?: {
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
};
}
/**
* Represents data about a project
*
* @deprecated
* (Maybe we should use PlanSummaryRecord instead, either by adding rowId or by iterating with jwtId.)
**/
export interface PlanData {
/**
* Description of the project
**/
description: string;
/**
* URL referencing information about the project
**/
handleId: string;
image?: string;
/**
* The DID of the issuer
*/
issuerDid: string;
/**
* Name of the project
**/
name: string;
/**
* The identifier of the project record -- different from jwtId
* (Maybe we should use the jwtId to iterate through the records instead.)
**/
rowId?: string;
}
export interface EndorserRateLimits {
doneClaimsThisWeek: string;
doneRegistrationsThisMonth: string;
maxClaimsPerWeek: string;
maxRegistrationsPerMonth: string;
nextMonthBeginDateTime: string;
nextWeekBeginDateTime: string;
}
export interface ImageRateLimits {
doneImagesThisWeek: string;
maxImagesPerWeek: string;
nextWeekBeginDateTime: string;
}
export interface VerifiableCredential {
exp?: number;
iat: number;
iss: string;
vc: {
"@context": string[];
type: string[];
credentialSubject: VerifiableCredentialSubject;
};
}
// similar to GenericVerifiableCredential... maybe replace that one
export interface VerifiableCredentialSubject {
"@context": string;
"@type": string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface WorldProperties {
startTime?: string;
endTime?: string;
}
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
"@context": typeof SCHEMA_ORG_CONTEXT;
"@type": "RegisterAction";
agent: { identifier: string };
identifier?: string; // used for invites (when participant ID isn't known)
object: string;
participant?: { identifier: string }; // used when person is known (not an invite)
}
// now for some of the error & other wrapper types
export interface ResultWithType {
type: string;
}
export interface SuccessResult extends ResultWithType {
type: "success";
response: AxiosResponse<ClaimResult>;
}
export interface ErrorResponse {
error?: {
message?: string;
};
}
export interface InternalError {
error: string; // for system logging
userMessage?: string; // for user display
}
export interface ErrorResult extends ResultWithType {
type: "error";
error: InternalError;
}
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
/**
* This is similar to Contact but it grew up in different logic paths.
* We may want to change this to be a Contact.
*/
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
// This is used to check for hidden info.
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN";
export function isDid(did: string) {
/**
* Validates if a string is a valid DID
* @param {string} did - The DID string to validate
* @returns {boolean} True if string is a valid DID format
*/
export function isDid(did: string): boolean {
return did.startsWith("did:");
}
export function isHiddenDid(did: string) {
/**
* Checks if a DID is the special hidden DID value
* @param {string} did - The DID to check
* @returns {boolean} True if DID is hidden
*/
export function isHiddenDid(did: string): boolean {
return did === HIDDEN_DID;
}
export function isEmptyOrHiddenDid(did?: string) {
return !did || did === HIDDEN_DID; // catching empty string as well
/**
* Checks if a DID is empty or hidden
* @param {string} [did] - The DID to check
* @returns {boolean} True if DID is empty or hidden
*/
export function isEmptyOrHiddenDid(did?: string): boolean {
return !did || did === HIDDEN_DID;
}
/**
* @return true for any string within this primitive/object/array where func(input) === true
* Recursively tests strings within an object/array against a test function
* @param {Function} func - Test function to apply to strings
* @param {any} input - Object/array to recursively test
* @returns {boolean} True if any string passes the test function
*
* Similar logic is found in endorser-mobile.
* @example
* testRecursivelyOnStrings(isDid, { user: { id: "did:example:123" } })
* // Returns: true
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function testRecursivelyOnStrings(func: (arg0: any) => boolean, input: any) {
/**
* Recursively tests strings within a nested object/array structure against a test function
*
* This function traverses through objects and arrays to find all string values and applies
* a test function to each string found. It handles:
* - Direct string values
* - Strings in objects (at any depth)
* - Strings in arrays (at any depth)
* - Mixed nested structures (objects containing arrays containing objects, etc)
*
* @param {Function} func - Test function that takes a string and returns boolean
* @param {any} input - Value to recursively search (can be string, object, array, or other)
* @returns {boolean} True if any string in the structure passes the test function
*
* @example
* // Test if any string is a DID
* const obj = {
* user: {
* id: "did:example:123",
* details: ["name", "did:example:456"]
* }
* };
* testRecursivelyOnStrings(isDid, obj); // Returns: true
*
* @example
* // Test for hidden DIDs
* const obj = {
* visible: "did:example:123",
* hidden: ["did:none:HIDDEN"]
* };
* testRecursivelyOnStrings(isHiddenDid, obj); // Returns: true
*/
function testRecursivelyOnStrings(
func: (arg0: unknown) => boolean,
input: unknown,
): boolean {
// Test direct string values
if (Object.prototype.toString.call(input) === "[object String]") {
return func(input);
} else if (input instanceof Object) {
}
// Recursively test objects and arrays
else if (input instanceof Object) {
if (!Array.isArray(input)) {
// it's an object
// Handle plain objects
for (const key in input) {
if (testRecursivelyOnStrings(func, input[key])) {
return true;
}
}
} else {
// it's an array
// Handle arrays
for (const value of input) {
if (testRecursivelyOnStrings(func, value)) {
return true;
@@ -351,6 +201,7 @@ function testRecursivelyOnStrings(func: (arg0: any) => boolean, input: any) {
}
return false;
} else {
// Non-string, non-object values can't contain strings
return false;
}
}
@@ -617,15 +468,23 @@ export async function getHeaders(
return headers;
}
/**
* Cache for storing plan data
* @constant {LRUCache}
*/
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
/**
* @param handleId nullable, in which case "undefined" will be returned
* @param requesterDid optional, in which case no private info will be returned
* @param axios
* @param apiServer
* Retrieves plan data from cache or server
* @param {string} handleId - Plan handle ID
* @param {Axios} axios - Axios instance
* @param {string} apiServer - API server URL
* @param {string} [requesterDid] - Optional requester DID for private info
* @returns {Promise<PlanSummaryRecord|undefined>} Plan data or undefined if not found
*
* @throws {Error} If server request fails
*/
export async function getPlanFromCache(
handleId: string | undefined,
@@ -649,44 +508,44 @@ export async function getPlanFromCache(
cred = resp.data.data[0];
planCache.set(handleId, cred);
} else {
console.error(
"Failed to load plan with handle",
logger.log(
"[EndorserServer] Plan cache is empty for handle",
handleId,
" Got data:",
resp.data,
JSON.stringify(resp.data),
);
}
} catch (error) {
console.error(
"Failed to load plan with handle",
logger.error(
"[EndorserServer] Failed to load plan with handle",
handleId,
" Got error:",
error,
JSON.stringify(error),
);
}
}
return cred;
}
/**
* Updates plan data in cache
* @param {string} handleId - Plan handle ID
* @param {PlanSummaryRecord} planSummary - Plan data to cache
*/
export async function setPlanInCache(
handleId: string,
planSummary: PlanSummaryRecord,
) {
): Promise<void> {
planCache.set(handleId, planSummary);
}
/**
*
* @param error that is thrown from an Endorser server call by Axios
* @returns user-friendly message, or undefined if none found
* Extracts user-friendly message from server error
* @param {any} error - Error thrown from Endorser server call
* @returns {string|undefined} User-friendly message or undefined if none found
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function serverMessageForUser(error: any) {
return (
// this is how most user messages are returned
error?.response?.data?.error?.message
// some are returned as "error" with a string, but those are more for devs and are less helpful to the user
);
export function serverMessageForUser(error: unknown): string | undefined {
return error?.response?.data?.error?.message;
}
/**
@@ -696,7 +555,7 @@ export function serverMessageForUser(error: any) {
* @param error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function errorStringForLog(error: any) {
export function errorStringForLog(error: unknown) {
let stringifiedError = "" + error;
try {
stringifiedError = JSON.stringify(error);
@@ -1126,7 +985,7 @@ export async function createAndSubmitClaim(
return { type: "success", response };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error submitting claim:", error);
logger.error("Error submitting claim:", error);
const errorMessage: string =
serverMessageForUser(error) ||
error.message ||
@@ -1472,7 +1331,7 @@ export async function register(
}
return { error: message };
} else {
console.error(resp);
logger.error(resp);
return { error: "Got a server error when registering." };
}
}
@@ -1502,7 +1361,7 @@ export async function setVisibilityUtil(
}
return { success };
} else {
console.error(
logger.error(
"Got some bad server response when setting visibility: ",
resp.status,
resp,
@@ -1512,7 +1371,7 @@ export async function setVisibilityUtil(
return { error: message };
}
} catch (err) {
console.error("Got some error when setting visibility:", err);
logger.error("Got some error when setting visibility:", err);
return { error: "Check connectivity and try again." };
}
}

View File

@@ -27,6 +27,7 @@ import {
import { KeyMeta } from "../libs/crypto/vc";
import { createPeerDid } from "../libs/crypto/vc/didPeer";
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
import { logger } from "../utils/logger";
export interface GiverReceiverInputInfo {
did?: string;
@@ -98,6 +99,11 @@ export function numberOrZero(str: string): number {
return isNumeric(str) ? +str : 0;
}
/**
* from https://tools.ietf.org/html/rfc3986#section-3
* also useful is https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Definition
**/
export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
};
@@ -222,7 +228,7 @@ export async function retrieveConfirmerIdList(
};
return result;
} else {
console.error(
logger.error(
"Bad response status of",
response.status,
"for confirmers:",
@@ -610,7 +616,7 @@ export const sendTestThroughPushServer = async (
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
};
console.log("Sending a test web push message:", newPayload);
logger.log("Sending a test web push message:", newPayload);
const payloadStr = JSON.stringify(newPayload);
const response = await axios.post(
pushUrl + "/web-push/send-test",
@@ -622,6 +628,6 @@ export const sendTestThroughPushServer = async (
},
);
console.log("Got response from web push server:", response);
logger.log("Got response from web push server:", response);
return response;
};

90
src/main.capacitor.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* @file Capacitor Main Entry Point
* @author Matthew Raymer
*
* This file initializes the deep linking system for the TimeSafari app.
* It sets up the connection between Capacitor's URL handling and our deep link processor.
*
* Deep Linking Flow:
* 1. Capacitor receives URL open event
* 2. Event is passed to DeepLinkHandler
* 3. URL is validated and processed
* 4. Router navigates to appropriate view
*
* Integration Points:
* - Capacitor App plugin for URL handling
* - Vue Router for navigation
* - Error handling system
* - Logging system
*
* Type Safety:
* - Uses DeepLinkHandler for type-safe parameter processing
* - Ensures type safety between Capacitor events and app routing
* - Maintains type checking through the entire deep link flow
*
* @example
* // URL open event from OS
* timesafari://claim/123?view=details
* // Processed and routed to appropriate view with type-safe parameters
*/
import { initializeApp } from "./main.common";
import { App } from "./lib/capacitor/app";
import router from "./router";
import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logConsoleAndDb } from "./db";
import { logger } from "./utils/logger";
logger.log("[Capacitor] Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
const app = initializeApp();
// Initialize API error handling for unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
if (event.reason?.response) {
handleApiError(event.reason, event.reason.config?.url || "unknown");
}
});
const deepLinkHandler = new DeepLinkHandler(router);
/**
* Handles deep link routing for the application
* Processes URLs in the format timesafari://<route>/<param>
* Maps incoming deep links to corresponding router paths with parameters
*
* @param {Object} data - Deep link data object
* @param {string} data.url - The full deep link URL to process
* @returns {Promise<void>}
*
* @example
* // Handles URLs like:
* // timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H
* // timesafari://project/abc123
*
* @throws {Error} If URL format is invalid
*/
const handleDeepLink = async (data: { url: string }) => {
try {
await router.isReady();
await deepLinkHandler.handleDeepLink(data.url);
} catch (error) {
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true);
handleApiError(
{
message: error instanceof Error ? error.message : String(error),
} as AxiosError,
"deep-link",
);
}
};
// Register deep link handler with Capacitor
App.addListener("appUrlOpen", handleDeepLink);
logger.log("[Capacitor] Mounting app");
app.mount("#app");
logger.log("[Capacitor] App mounted");

61
src/main.common.ts Normal file
View File

@@ -0,0 +1,61 @@
import { createPinia } from "pinia";
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import axios from "axios";
import VueAxios from "vue-axios";
import Notifications from "notiwind";
import "./assets/styles/tailwind.css";
import { FontAwesomeIcon } from "./lib/fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";
// Global Error Handler
function setupGlobalErrorHandler(app: VueApp) {
logger.log("[App Init] Setting up global error handler");
app.config.errorHandler = (
err: unknown,
instance: ComponentPublicInstance | null,
info: string,
) => {
logger.error("[App Error] Global Error Handler:", {
error: err,
info,
component: instance?.$options.name || "unknown",
});
alert(
(err instanceof Error ? err.message : "Something bad happened") +
" - Try reloading or restarting the app.",
);
};
}
// Function to initialize the app
export function initializeApp() {
logger.log("[App Init] Starting app initialization");
logger.log("[App Init] Platform:", process.env.VITE_PLATFORM);
const app = createApp(App);
logger.log("[App Init] Vue app created");
app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera);
logger.log("[App Init] Components registered");
const pinia = createPinia();
app.use(pinia);
logger.log("[App Init] Pinia store initialized");
app.use(VueAxios, axios);
logger.log("[App Init] Axios initialized");
app.use(router);
logger.log("[App Init] Router initialized");
app.use(Notifications);
logger.log("[App Init] Notifications initialized");
setupGlobalErrorHandler(app);
logger.log("[App Init] App initialization complete");
return app;
}

4
src/main.electron.ts Normal file
View File

@@ -0,0 +1,4 @@
import { initializeApp } from "./main.common";
const app = initializeApp();
app.mount("#app");

4
src/main.pywebview.ts Normal file
View File

@@ -0,0 +1,4 @@
import { initializeApp } from "./main.common";
const app = initializeApp();
app.mount("#app");

View File

@@ -6,170 +6,10 @@ import router from "./router";
import axios from "axios";
import VueAxios from "vue-axios";
import Notifications from "notiwind";
import "./assets/styles/tailwind.css";
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleUser,
faClock,
faCoins,
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQuestion,
faQrcode,
faRightFromBracket,
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faTrashCan,
faTriangleExclamation,
faUser,
faUsers,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
library.add(
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleUser,
faClock,
faCoins,
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQrcode,
faQuestion,
faRotate,
faRightFromBracket,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faTrashCan,
faTriangleExclamation,
faUser,
faUsers,
faXmark,
);
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { FontAwesomeIcon } from "./lib/fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
function setupGlobalErrorHandler(app: VueApp) {
@@ -179,7 +19,7 @@ function setupGlobalErrorHandler(app: VueApp) {
instance: ComponentPublicInstance | null,
info: string,
) => {
console.error(
logger.error(
"Ouch! Global Error Handler.",
"Error:",
err,
@@ -197,7 +37,7 @@ function setupGlobalErrorHandler(app: VueApp) {
);
};
}
// console.log("Bootstrapping Vue app...");
const app = createApp(App)
.component("fa", FontAwesomeIcon)
.component("camera", Camera)
@@ -209,4 +49,3 @@ const app = createApp(App)
setupGlobalErrorHandler(app);
app.mount("#app");
// console.log("Vue app mounted.");

5
src/main.web.ts Normal file
View File

@@ -0,0 +1,5 @@
import { initializeApp } from "./main.common";
import "./registerServiceWorker"; // Web PWA support
const app = initializeApp();
app.mount("#app");

View File

@@ -7,6 +7,7 @@ import {
RouteRecordRaw,
} from "vue-router";
import { accountsDBPromise } from "../db/index";
import { logger } from "../utils/logger";
/**
*
@@ -276,8 +277,8 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("../views/TestView.vue"),
},
{
path: "/userProfile/:id?",
name: "userProfile",
path: "/user-profile/:id?",
name: "user-profile",
component: () => import("../views/UserProfileView.vue"),
},
];
@@ -307,7 +308,7 @@ const errorHandler = (
from: RouteLocationNormalized,
) => {
// Handle the error here
console.error("Caught in top level error handler:", error, to, from);
logger.error("Caught in top level error handler:", error, to, from);
alert("Something is very wrong. Try reloading or restarting the app.");
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page

24
src/services/api.ts Normal file
View File

@@ -0,0 +1,24 @@
import { AxiosError } from "axios";
import { logger } from "../utils/logger";
export const handleApiError = (error: AxiosError, endpoint: string) => {
if (process.env.VITE_PLATFORM === "capacitor") {
logger.error(`[Capacitor API Error] ${endpoint}:`, {
message: error.message,
status: error.response?.status,
data: error.response?.data,
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers,
},
});
}
// Specific handling for rate limits
if (error.response?.status === 400) {
logger.warn(`[Rate Limit] ${endpoint}`);
return null;
}
throw error;
};

148
src/services/deepLinks.ts Normal file
View File

@@ -0,0 +1,148 @@
/**
* @file Deep Link Handler Service
* @author Matthew Raymer
*
* This service handles the processing and routing of deep links in the TimeSafari app.
* It provides a type-safe interface between the raw deep links and the application router.
*
* Architecture:
* 1. DeepLinkHandler class encapsulates all deep link processing logic
* 2. Uses Zod schemas from types/deepLinks for parameter validation
* 3. Provides consistent error handling and logging
* 4. Maps validated parameters to Vue router calls
*
* Error Handling Strategy:
* - All errors are wrapped in DeepLinkError interface
* - Errors include error codes for systematic handling
* - Detailed error information is logged for debugging
* - Errors are propagated to the global error handler
*
* Validation Strategy:
* - URL structure validation
* - Route-specific parameter validation using Zod schemas
* - Query parameter validation and sanitization
* - Type-safe parameter passing to router
*
* @example
* const handler = new DeepLinkHandler(router);
* await handler.handleDeepLink("timesafari://claim/123?view=details");
*/
import { Router } from "vue-router";
import { deepLinkSchemas, baseUrlSchema } from "../types/deepLinks";
import { logConsoleAndDb } from "../db";
import type { DeepLinkError } from "../interfaces/deepLinks";
export class DeepLinkHandler {
private router: Router;
constructor(router: Router) {
this.router = router;
}
/**
* Parses deep link URL into path, params and query components
*/
private parseDeepLink(url: string) {
const parts = url.split("://");
if (parts.length !== 2) {
throw { code: "INVALID_URL", message: "Invalid URL format" };
}
// Validate base URL structure
baseUrlSchema.parse({
scheme: parts[0],
path: parts[1],
queryParams: {}, // Will be populated below
});
const [path, queryString] = parts[1].split("?");
const [routePath, param] = path.split("/");
const query: Record<string, string> = {};
if (queryString) {
new URLSearchParams(queryString).forEach((value, key) => {
query[key] = value;
});
}
return {
path: routePath,
params: param ? { id: param } : {},
query,
};
}
/**
* Processes incoming deep links and routes them appropriately
* @param url The deep link URL to process
*/
async handleDeepLink(url: string): Promise<void> {
try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
};
}
}
/**
* Routes the deep link to appropriate view with validated parameters
*/
private async validateAndRoute(
path: string,
params: Record<string, string>,
query: Record<string, string>,
): Promise<void> {
const routeMap: Record<string, string> = {
"user-profile": "user-profile",
project: "project",
"onboard-meeting-setup": "onboard-meeting-setup",
"invite-one-accept": "invite-one-accept",
"contact-import": "contact-import",
"confirm-gift": "confirm-gift",
claim: "claim",
"claim-cert": "claim-cert",
"claim-add-raw": "claim-add-raw",
"contact-edit": "contact-edit",
contacts: "contacts",
did: "did",
};
const routeName = routeMap[path];
if (!routeName) {
throw {
code: "INVALID_ROUTE",
message: `Unsupported route: ${path}`,
};
}
// Validate parameters based on route type
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas];
const validatedParams = await schema.parseAsync({
...params,
...query,
});
await this.router.replace({
name: routeName,
params: validatedParams,
query,
});
}
}

81
src/services/plan.ts Normal file
View File

@@ -0,0 +1,81 @@
import axios from "axios";
import { logger } from "../utils/logger";
interface PlanResponse {
data?: unknown;
status?: number;
error?: string;
}
export const loadPlanWithRetry = async (
handle: string,
retries = 3,
): Promise<PlanResponse> => {
try {
logger.log(`[Plan Service] Loading plan ${handle}, attempt 1/${retries}`);
logger.log(
`[Plan Service] Context: Deep link handle=${handle}, isClaimFlow=${handle.includes("claim")}`,
);
// Different endpoint if this is a claim flow
const response = await loadPlan(handle);
logger.log(`[Plan Service] Plan ${handle} loaded successfully:`, {
status: response?.status,
headers: response?.headers,
data: response?.data,
});
return response;
} catch (error: unknown) {
logger.error(`[Plan Service] Error loading plan ${handle}:`, {
message: (error as Error).message,
status: (error as { response?: { status?: number } })?.response?.status,
statusText: (error as { response?: { statusText?: string } })?.response
?.statusText,
data: (error as { response?: { data?: unknown } })?.response?.data,
headers: (error as { response?: { headers?: unknown } })?.response
?.headers,
config: {
url: (error as { config?: { url?: string } })?.config?.url,
method: (error as { config?: { method?: string } })?.config?.method,
baseURL: (error as { config?: { baseURL?: string } })?.config?.baseURL,
headers: (error as { config?: { headers?: unknown } })?.config?.headers,
},
});
if (retries > 1) {
logger.log(
`[Plan Service] Retrying plan ${handle}, ${retries - 1} attempts remaining`,
);
await new Promise((resolve) => setTimeout(resolve, 1000));
return loadPlanWithRetry(handle, retries - 1);
}
return {
error: `Failed to load plan ${handle} after ${4 - retries} attempts: ${(error as Error).message}`,
status: (error as { response?: { status?: number } })?.response?.status,
};
}
};
export const loadPlan = async (handle: string): Promise<PlanResponse> => {
logger.log(`[Plan Service] Making API request for plan ${handle}`);
const endpoint = handle.includes("claim")
? `/api/claims/${handle}`
: `/api/plans/${handle}`;
logger.log(`[Plan Service] Using endpoint: ${endpoint}`);
try {
const response = await axios.get(endpoint);
return response;
} catch (error: unknown) {
logger.error(`[Plan Service] API request failed for ${handle}:`, {
endpoint,
error: (error as Error).message,
response: (error as { response?: { data?: unknown } })?.response?.data,
});
throw error;
}
};

View File

@@ -4,7 +4,7 @@ import { AppString } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db";
import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto";
import { logger } from "../utils/logger";
/**
* Get User #0 to sign & submit a RegisterAction for the user's activeDid.
*/
@@ -58,5 +58,5 @@ export async function testServerRegisterUser() {
};
const resp = await axios.post(url, payload, { headers });
console.log("User registration result:", resp);
logger.log("User registration result:", resp);
}

81
src/types/deepLinks.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* @file Deep Link Type Definitions and Validation Schemas
* @author Matthew Raymer
*
* This file defines the type system and validation schemas for deep linking in the TimeSafari app.
* It uses Zod for runtime validation while providing TypeScript types for compile-time checking.
*
* Type Strategy:
* 1. Define base URL schema to validate the fundamental deep link structure
* 2. Define route-specific parameter schemas with exact validation rules
* 3. Generate TypeScript types from Zod schemas for type safety
* 4. Export both schemas and types for use in deep link handling
*
* Usage:
* - Import schemas for runtime validation in deep link handlers
* - Import types for type-safe parameter handling in components
* - Use DeepLinkParams type for type-safe access to route parameters
*
* @example
* // Runtime validation
* const params = deepLinkSchemas.claim.parse({ id: "123", view: "details" });
*
* // Type-safe parameter access
* function handleClaimParams(params: DeepLinkParams["claim"]) {
* // TypeScript knows params.id exists and params.view is optional
* }
*/
import { z } from "zod";
// Base URL validation schema
export const baseUrlSchema = z.object({
scheme: z.literal("timesafari"),
path: z.string(),
queryParams: z.record(z.string()).optional(),
});
// Parameter validation schemas for each route type
export const deepLinkSchemas = {
"user-profile": z.object({
id: z.string(),
}),
"project-details": z.object({
id: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
"invite-one-accept": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
claim: z.object({
id: z.string(),
}),
"claim-cert": z.object({
id: z.string(),
}),
"claim-add-raw": z.object({
id: z.string(),
claim: z.string().optional(),
claimJwtId: z.string().optional(),
}),
"contact-edit": z.object({
did: z.string(),
}),
contacts: z.object({
contacts: z.string(), // JSON string of contacts array
}),
did: z.object({
id: z.string(),
}),
};
export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
};

18
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,18 @@
export const logger = {
log: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.log(message, ...args);
}
},
warn: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.warn(message, ...args);
}
},
error: (message: string, ...args: unknown[]) => {
// eslint-disable-next-line no-console
console.error(message, ...args); // Errors should always be logged
},
};

View File

@@ -39,12 +39,15 @@
:to="{ name: 'contact-qr' }"
class="bg-slate-500 text-white px-1.5 py-1 rounded-md"
>
<fa icon="qrcode" class="fa-fw text-xl"></fa>
<font-awesome icon="qrcode" class="fa-fw text-xl"></font-awesome>
</router-link>
</span>
{{ givenName }}
<router-link :to="{ name: 'new-edit-account' }">
<fa icon="pen" class="text-xs text-blue-500 ml-2 mb-1"></fa>
<font-awesome
icon="pen"
class="text-xs text-blue-500 ml-2 mb-1"
></font-awesome>
</router-link>
</h2>
</div>
@@ -53,13 +56,13 @@
class="block w-full text-center text-md bg-amber-200 border border-dashed border-slate-400 px-1.5 py-2 rounded-md mb-2"
>
<button
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="
() =>
(this.$refs.userNameDialog as UserNameDialog).open(
(name) => (this.givenName = name),
($refs.userNameDialog as UserNameDialog).open(
(name) => (givenName = name),
)
"
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Set Your Name
</button>
@@ -69,23 +72,23 @@
<span v-if="profileImageUrl" class="flex justify-between">
<EntityIcon
:icon-size="96"
:profileImageUrl="profileImageUrl"
:profile-image-url="profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeIdenticonUrl = profileImageUrl"
/>
<fa
<font-awesome
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12"
@click="confirmDeleteImage"
/>
</span>
<div v-else class="text-center">
<div class @click="openImageDialog()">
<fa
<font-awesome
icon="image-portrait"
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 px-2 py-2 rounded-l"
/>
<fa
<font-awesome
icon="camera"
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 px-2 py-2 rounded-r"
/>
@@ -101,8 +104,8 @@
</div>
<div class="flex justify-center">
<EntityIcon
:entityId="activeDid"
:iconSize="64"
:entity-id="activeDid"
:icon-size="64"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
@click="showLargeIdenticonId = activeDid"
/>
@@ -116,9 +119,9 @@
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<EntityIcon
:entityId="showLargeIdenticonId"
:iconSize="512"
:profileImageUrl="showLargeIdenticonUrl"
:entity-id="showLargeIdenticonId"
:icon-size="512"
:profile-image-url="showLargeIdenticonUrl"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="
showLargeIdenticonId = undefined;
@@ -135,12 +138,12 @@
>
<code class="truncate">{{ activeDid }}</code>
<button
class="ml-2"
@click="
doCopyTwoSecRedo(activeDid, () => (showDidCopy = !showDidCopy))
"
class="ml-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
<font-awesome icon="copy" class="text-slate-400 fa-fw"></font-awesome>
</button>
<span v-show="showDidCopy">Copied</span>
</div>
@@ -185,7 +188,7 @@
<!-- label -->
<div>
Reminder Notification
<fa
<font-awesome
icon="question-circle"
class="text-slate-400 fa-fw ml-2 cursor-pointer"
@click.stop="showReminderNotificationInfo"
@@ -197,7 +200,7 @@
@click="showReminderNotificationChoice()"
>
<!-- input -->
<input type="checkbox" v-model="notifyingReminder" class="sr-only" />
<input v-model="notifyingReminder" type="checkbox" class="sr-only" />
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
@@ -214,7 +217,7 @@
<!-- label -->
<div>
New Activity Notification
<fa
<font-awesome
icon="question-circle"
class="text-slate-400 fa-fw ml-2 cursor-pointer"
@click.stop="showNewActivityNotificationInfo"
@@ -227,8 +230,8 @@
>
<!-- input -->
<input
type="checkbox"
v-model="notifyingNewActivity"
type="checkbox"
class="sr-only"
/>
<!-- line -->
@@ -268,12 +271,15 @@
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<div v-if="loadingProfile" class="text-center mb-2">
<fa icon="spinner" class="fa-spin text-slate-400"></fa> Loading
profile...
<font-awesome
icon="spinner"
class="fa-spin text-slate-400"
></font-awesome>
Loading profile...
</div>
<div v-else class="flex items-center mb-2">
<span class="font-bold">Public Profile</span>
<fa
<font-awesome
icon="circle-info"
class="text-slate-400 fa-fw ml-2 cursor-pointer"
@click="showProfileInfo"
@@ -289,9 +295,9 @@
<div class="flex items-center mb-4" @click="toggleUserProfileLocation">
<input
v-model="includeUserProfileLocation"
type="checkbox"
class="mr-2"
v-model="includeUserProfileLocation"
/>
<label for="includeUserProfileLocation">Include Location</label>
</div>
@@ -327,17 +333,16 @@
<div v-if="!loadingProfile && !savingProfile">
<div class="flex justify-between items-center">
<button
@click="saveProfile"
class="mt-2 px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
:disabled="loadingProfile || savingProfile"
:class="{
'opacity-50 cursor-not-allowed': loadingProfile || savingProfile,
}"
@click="saveProfile"
>
Save Profile
</button>
<button
@click="confirmDeleteProfile"
class="mt-2 px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
:disabled="loadingProfile || savingProfile"
:class="{
@@ -346,6 +351,7 @@
savingProfile ||
(!userProfileDesc && !includeUserProfileLocation),
}"
@click="confirmDeleteProfile"
>
Delete Profile
</button>
@@ -363,7 +369,8 @@
<div class="mb-2 font-bold">Usage Limits</div>
<!-- show spinner if loading limits -->
<div v-if="loadingLimits" class="text-center">
Checking&hellip; <fa icon="spinner" class="fa-spin"></fa>
Checking&hellip;
<font-awesome icon="spinner" class="fa-spin"></font-awesome>
</div>
<div class="mb-4 text-center">
{{ limitsMessage }}
@@ -419,15 +426,15 @@
>
<div class="mb-2 font-bold">Data Export</div>
<router-link
:to="{ name: 'seed-backup' }"
v-if="activeDid"
:to="{ name: 'seed-backup' }"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Backup Identifier Seed
</router-link>
<button
v-bind:class="computedStartDownloadLinkClassNames()"
:class="computedStartDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="exportDatabase()"
>
@@ -437,7 +444,7 @@
</button>
<a
ref="downloadLink"
v-bind:class="computedDownloadLinkClassNames()"
:class="computedDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
>
If no download happened yet, click again here to download now.
@@ -454,7 +461,7 @@
</li>
<li class="list-disc list-outside ml-4">
On Android: Choose "Open" and then share
<fa icon="share-nodes" class="fa-fw" />
<font-awesome icon="share-nodes" class="fa-fw" />
to your prefered place.
</li>
</ul>
@@ -469,7 +476,7 @@
>
Advanced
</h3>
<div id="sectionAdvanced" v-if="showAdvanced || showGeneralAdvanced">
<div v-if="showAdvanced || showGeneralAdvanced" id="sectionAdvanced">
<p class="text-rose-600 mb-8">
Beware: the features here can be confusing and even change data in ways
you do not expect. But we support your freedom!
@@ -489,12 +496,15 @@
>
<code class="truncate">{{ publicBase64 }}</code>
<button
class="ml-2"
@click="
doCopyTwoSecRedo(publicBase64, () => (showB64Copy = !showB64Copy))
"
class="ml-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
></font-awesome>
</button>
<span v-show="showB64Copy">Copied</span>
</div>
@@ -505,12 +515,15 @@
>
<code class="truncate">{{ publicHex }}</code>
<button
class="ml-2"
@click="
doCopyTwoSecRedo(publicHex, () => (showPubCopy = !showPubCopy))
"
class="ml-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
></font-awesome>
</button>
<span v-show="showPubCopy">Copied</span>
</div>
@@ -522,15 +535,18 @@
>
<code class="truncate">{{ derivationPath }}</code>
<button
class="ml-2"
@click="
doCopyTwoSecRedo(
derivationPath,
() => (showDerCopy = !showDerCopy),
)
"
class="ml-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
></font-awesome>
</button>
<span v-show="showDerCopy">Copied</span>
</div>
@@ -557,7 +573,7 @@
</h2>
<div class="ml-4 mt-2">
<input type="file" @change="uploadImportFile" class="ml-2" />
<input type="file" class="ml-2" @change="uploadImportFile" />
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
@@ -604,8 +620,8 @@
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="showContactGives"
type="checkbox"
name="showContactGives"
class="sr-only"
/>
@@ -622,16 +638,20 @@
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
<div class="px-4 py-4">
<input
v-model="apiServerInput"
type="text"
class="block w-full rounded border border-slate-400 px-4 py-2"
v-model="apiServerInput"
/>
<button
v-if="apiServerInput != apiServer"
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
@click="onClickSaveApiServer()"
>
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
<font-awesome
icon="floppy-disk"
class="fa-fw"
color="white"
></font-awesome>
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@@ -663,7 +683,7 @@
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input type="checkbox" v-model="warnIfProdServer" class="sr-only" />
<input v-model="warnIfProdServer" type="checkbox" class="sr-only" />
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
@@ -683,7 +703,7 @@
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input type="checkbox" v-model="warnIfTestServer" class="sr-only" />
<input v-model="warnIfTestServer" type="checkbox" class="sr-only" />
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
@@ -699,16 +719,20 @@
</h2>
<div class="px-3 py-4">
<input
v-model="webPushServerInput"
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2"
v-model="webPushServerInput"
/>
<button
v-if="webPushServerInput != webPushServer"
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
@click="onClickSavePushServer()"
>
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
<font-awesome
icon="floppy-disk"
class="fa-fw"
color="white"
></font-awesome>
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@@ -729,7 +753,7 @@
Use Test 2
</button>
</div>
<span class="px-4 text-sm" v-if="!webPushServerInput">
<span v-if="!webPushServerInput" class="px-4 text-sm">
When that setting is blank, this app will use the default web push
server URL:
{{ DEFAULT_PUSH_SERVER }}
@@ -738,16 +762,20 @@
<h2 class="text-slate-500 text-sm font-bold mb-2">Partner Server URL</h2>
<div class="px-3 py-4">
<input
v-model="partnerApiServerInput"
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2"
v-model="partnerApiServerInput"
/>
<button
v-if="partnerApiServerInput != partnerApiServer"
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
@click="onClickSavePartnerServer()"
>
<fa icon="floppy-disk" class="fa-fw" color="white"></fa>
<font-awesome
icon="floppy-disk"
class="fa-fw"
color="white"
></font-awesome>
</button>
<button
class="px-3 rounded bg-slate-200 border border-slate-400"
@@ -768,7 +796,7 @@
Use Local
</button>
</div>
<span class="px-4 text-sm" v-if="!partnerApiServerInput">
<span v-if="!partnerApiServerInput" class="px-4 text-sm">
When that setting is blank, this app will use the default partner server
URL:
{{ DEFAULT_PARTNER_API_SERVER }}
@@ -793,8 +821,8 @@
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="hideRegisterPromptOnNewContact"
type="checkbox"
class="sr-only"
/>
<!-- line -->
@@ -818,7 +846,7 @@
<!-- toggle -->
<div class="relative ml-2">
<!-- input -->
<input type="checkbox" v-model="showShortcutBvc" class="sr-only" />
<input v-model="showShortcutBvc" type="checkbox" class="sr-only" />
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
<!-- dot -->
@@ -851,9 +879,9 @@
</span>
<div class="relative ml-2">
<input
v-model="passkeyExpirationMinutes"
type="number"
class="border border-slate-400 rounded px-2 py-2 text-center w-20"
v-model="passkeyExpirationMinutes"
@change="updatePasskeyExpiration"
/>
</div>
@@ -872,8 +900,8 @@
<div class="relative ml-2">
<!-- input -->
<input
type="checkbox"
v-model="showGeneralAdvanced"
type="checkbox"
class="sr-only"
/>
<!-- line -->
@@ -895,13 +923,13 @@ import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import Dexie from "dexie";
import "dexie-export-import";
import { ImportProgress } from "dexie-export-import/dist/import";
import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
@@ -947,6 +975,8 @@ import {
DIRECT_PUSH_TITLE,
retrieveAccountMetadata,
} from "../libs/util";
import { UserProfile } from "@/libs/partnerServer";
import { logger } from "../utils/logger";
const inputImportFileNameRef = ref<Blob>();
@@ -966,6 +996,8 @@ const inputImportFileNameRef = ref<Blob>();
})
export default class AccountViewView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
AppConstants = AppString;
DEFAULT_PUSH_SERVER = DEFAULT_PUSH_SERVER;
@@ -1078,12 +1110,12 @@ export default class AccountViewView extends Vue {
}
} catch (error) {
// this can happen when running automated tests in dev mode because notifications don't work
console.error(
logger.error(
"Telling user to clear cache at page create because:",
error,
);
// this sometimes gives different information on the error
console.error(
logger.error(
"To repeat with concatenated error: telling user to clear cache at page create because: " +
error,
);
@@ -1490,7 +1522,7 @@ export default class AccountViewView extends Vue {
* @param {Error} error - The error object.
*/
private handleExportError(error: unknown) {
console.error("Export Error:", error);
logger.error("Export Error:", error);
this.$notify(
{
group: "alert",
@@ -1560,7 +1592,7 @@ export default class AccountViewView extends Vue {
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
console.error("Error checking contact imports:", error);
logger.error("Error checking contact imports:", error);
this.$notify(
{
group: "alert",
@@ -1576,7 +1608,7 @@ export default class AccountViewView extends Vue {
}
private progressCallback(progress: ImportProgress) {
console.log(
logger.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,
);
if (progress.done) {
@@ -1628,7 +1660,7 @@ export default class AccountViewView extends Vue {
await updateAccountSettings(did, { isRegistered: true });
this.isRegistered = true;
} catch (err) {
console.error("Got an error updating settings:", err);
logger.error("Got an error updating settings:", err);
this.$notify(
{
group: "alert",
@@ -1667,7 +1699,7 @@ export default class AccountViewView extends Vue {
if (error instanceof AxiosError) {
if (error.status == 400 || error.status == 404) {
// no worries: they probably just aren't registered and don't have any limits
console.log(
logger.log(
"Got 400 or 404 response retrieving limits which probably means they're not registered:",
error,
);
@@ -1676,11 +1708,11 @@ export default class AccountViewView extends Vue {
const data = error.response?.data as ErrorResponse;
this.limitsMessage =
(data?.error?.message as string) || "Bad server response.";
console.error("Got bad response retrieving limits:", error);
logger.error("Got bad response retrieving limits:", error);
}
} else {
this.limitsMessage = "Got an error retrieving limits.";
console.error("Got some error retrieving limits:", error);
logger.error("Got some error retrieving limits:", error);
}
}
@@ -1757,7 +1789,7 @@ export default class AccountViewView extends Vue {
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
console.log(
logger.log(
"Using shared image API server, so only users on that server can play with images.",
);
}
@@ -1771,7 +1803,7 @@ export default class AccountViewView extends Vue {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Non-success deleting image:", response);
logger.error("Non-success deleting image:", response);
this.$notify(
{
group: "alert",
@@ -1791,10 +1823,10 @@ export default class AccountViewView extends Vue {
this.profileImageUrl = undefined;
} catch (error) {
console.error("Error deleting image:", error);
logger.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.error("The image was already deleted:", error);
logger.error("The image was already deleted:", error);
await updateAccountSettings(this.activeDid, {
profileImageUrl: undefined,

View File

@@ -7,17 +7,17 @@
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw" />
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
Raw Claim
</h1>
</div>
<div class="flex">
<textarea rows="20" class="border-2 w-full" v-model="claimStr"></textarea>
<textarea v-model="claimStr" rows="20" class="border-2 w-full"></textarea>
</div>
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@@ -30,7 +30,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AxiosInstance } from "axios";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -38,76 +38,153 @@ import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "../db/index";
import * as serverUtil from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { errorStringForLog } from "../libs/endorserServer";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { logger } from "../utils/logger";
/**
* View component for adding or editing raw claim data
* Allows direct JSON editing of claim data with validation and submission
*/
@Component({
components: { QuickNav },
})
export default class ClaimAddRawView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
axios!: AxiosInstance;
accountIdentityStr: string = "null";
activeDid = "";
apiServer = "";
claimStr = "";
/**
* Lifecycle hook that initializes the view
* Workflow:
* 1. Retrieves active DID and API server from settings
* 2. Checks for existing claim data from query params:
* - If "claim" param exists: Parses and formats JSON
* - If "claimJwtId" param exists: Fetches claim data from API
* 3. Populates textarea with formatted claim data
*/
async mounted() {
await this.initializeSettings();
await this.loadClaimData();
}
/**
* Initialize settings from active account
*/
private async initializeSettings() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
}
this.claimStr = (this.$route as Router).query["claim"];
if (this.claimStr) {
try {
const veriClaim = JSON.parse(this.claimStr);
this.claimStr = JSON.stringify(veriClaim, null, 2);
} catch (e) {
// ignore a parse error
}
} else {
// there may be no link that uses this, meaning you'd have to enter it in a browser
const claimJwtId = (this.$route as Router).query["claimJwtId"];
if (claimJwtId) {
const urlPath = libsUtil.isGlobalUri(claimJwtId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimJwtId);
const headers = await serverUtil.getHeaders(this.activeDid);
/**
* Load claim data from query parameters or API
*/
private async loadClaimData() {
// Try loading from direct claim parameter
if (await this.loadClaimFromQueryParam()) return;
try {
const response = await this.axios.get(url, { headers });
if (response.status === 200) {
const claim = response.data?.claim;
claim.lastClaimId = serverUtil.stripEndorserPrefix(claimJwtId);
this.claimStr = JSON.stringify(claim, null, 2);
} else {
throw {
message: "Got an error loading that claim.",
response: {
status: response.status,
statusText: response.statusText,
// url is in "fetch" response but not in AxiosResponse
},
};
}
} catch (error: unknown) {
logConsoleAndDb(
"Error retrieving claim: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error retrieving claim data.",
},
3000,
);
}
}
// Try loading from claim JWT ID
await this.loadClaimFromJwtId();
}
/**
* Attempt to load claim from query parameter
* @returns true if claim was loaded successfully
*/
private async loadClaimFromQueryParam(): Promise<boolean> {
this.claimStr = (this.$route.query["claim"] as string) || "";
if (!this.claimStr) return false;
try {
const veriClaim = JSON.parse(this.claimStr);
this.claimStr = JSON.stringify(veriClaim, null, 2);
return true;
} catch (e) {
// ignore parse error
return false;
}
}
/**
* Load claim data from JWT ID via API
*/
private async loadClaimFromJwtId() {
const claimJwtId = (this.$route.query["claimJwtId"] as string) || "";
if (!claimJwtId) return;
const urlPath = libsUtil.isGlobalUri(claimJwtId)
? "/api/claim/byHandle/"
: "/api/claim/";
const url = this.apiServer + urlPath + encodeURIComponent(claimJwtId);
try {
const response = await this.fetchClaimData(url);
this.formatClaimResponse(response, claimJwtId);
} catch (error: unknown) {
this.handleClaimError(error);
}
}
/**
* Fetch claim data from API
*/
private async fetchClaimData(url: string) {
const headers = await serverUtil.getHeaders(this.activeDid);
return await this.axios.get(url, { headers });
}
/**
* Format successful claim response data
*/
private formatClaimResponse(response: unknown, claimJwtId: string) {
if (response.status === 200) {
const claim = response.data?.claim;
claim.lastClaimId = serverUtil.stripEndorserPrefix(claimJwtId);
this.claimStr = JSON.stringify(claim, null, 2);
} else {
throw {
message: "Got an error loading that claim.",
response: {
status: response.status,
statusText: response.statusText,
},
};
}
}
/**
* Handle error loading claim data
*/
private handleClaimError(error: unknown) {
logConsoleAndDb(
"Error retrieving claim: " + errorStringForLog(error),
true,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Got an error retrieving claim data.",
},
3000,
);
}
/**
* Submits the edited claim data
* Workflow:
* 1. Parses JSON from textarea
* 2. Sends to server via createAndSubmitClaim
* 3. Shows success/error notification
* @throws Will show error notification if submission fails
*/
async submitClaim() {
const fullClaim = JSON.parse(this.claimStr);
const result = await serverUtil.createAndSubmitClaim(
@@ -127,7 +204,7 @@ export default class ClaimAddRawView extends Vue {
5000,
);
} else {
console.error("Got error submitting the claim:", result);
logger.error("Got error submitting the claim:", result);
this.$notify(
{
group: "alert",

View File

@@ -2,8 +2,8 @@
<section id="Content">
<div class="flex items-center justify-center h-screen">
<div v-if="claimData">
<router-link :to="'/claim/' + this.claimId">
<canvas class="w-full block mx-auto" ref="claimCanvas"></canvas>
<router-link :to="'/claim/' + claimId">
<canvas ref="claimCanvas" class="w-full block mx-auto"></canvas>
</router-link>
</div>
</div>
@@ -14,11 +14,11 @@
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as serverUtil from "../libs/endorserServer";
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
import { logger } from "../utils/logger";
@Component
export default class ClaimCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@@ -70,7 +70,7 @@ export default class ClaimCertificateView extends Vue {
throw new Error(`Error fetching claim: ${response.statusText}`);
}
} catch (error) {
console.error("Failed to load claim:", error);
logger.error("Failed to load claim:", error);
this.$notify({
group: "alert",
type: "danger",
@@ -81,7 +81,7 @@ export default class ClaimCertificateView extends Vue {
}
async drawCanvas(
claimData: serverUtil.GenericCredWrapper<serverUtil.GenericVerifiableCredential>,
claimData: GenericCredWrapper<GenericVerifiableCredential>,
confirmerIds: Array<string>,
) {
await db.open();

View File

@@ -6,16 +6,6 @@
</section>
</template>
<style scoped>
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
@@ -24,7 +14,7 @@ import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as endorserServer from "../libs/endorserServer";
import { logger } from "../utils/logger";
@Component
export default class ClaimReportCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@@ -63,7 +53,7 @@ export default class ClaimReportCertificateView extends Vue {
throw new Error(`Error fetching claim: ${response.statusText}`);
}
} catch (error) {
console.error("Failed to load claim:", error);
logger.error("Failed to load claim:", error);
this.$notify({
group: "alert",
type: "danger",
@@ -188,3 +178,13 @@ export default class ClaimReportCertificateView extends Vue {
}
}
</script>
<style scoped>
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>

View File

@@ -7,10 +7,10 @@
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<fa icon="chevron-left" class="fa-fw" />
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
Verifiable Claim Details
</h1>
@@ -22,7 +22,9 @@
<div class="w-full">
<div class="flex columns-3">
<h2 class="text-md font-bold w-full">
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
{{
capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType || "")
}}
<button
v-if="
['GiveAction', 'Offer', 'PlanAction'].includes(
@@ -32,11 +34,14 @@
// but rather than add more Plan-specific logic to detect the agent
// we'll let them click the Project link and edit from there
"
@click="onClickEditClaim"
title="Edit"
data-testId="editClaimButton"
@click="onClickEditClaim"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
<font-awesome
icon="pen"
class="text-sm text-blue-500 ml-2 mb-1"
/>
</button>
</h2>
<div class="flex justify-center w-full">
@@ -45,7 +50,10 @@
class="text-blue-500 mt-2"
title="Printable Certificate"
>
<fa icon="square" class="text-white bg-yellow-500 p-1" />
<font-awesome
icon="square"
class="text-white bg-yellow-500 p-1"
/>
</router-link>
</div>
<!-- show link icon to copy this URL to the clipboard -->
@@ -56,30 +64,37 @@
copyToClipboard('A link to this page', window.location.href)
"
>
<fa icon="link" class="text-slate-500" />
<font-awesome icon="link" class="text-slate-500" />
</button>
</div>
</div>
<div class="text-sm">
<div data-testId="description">
<fa icon="message" class="fa-fw text-slate-400" />
<font-awesome icon="message" class="fa-fw text-slate-400" />
{{
veriClaim.claim?.itemOffered?.description ||
veriClaim.claim?.description
(veriClaim.claim?.itemOffered as any)?.description ||
(veriClaim.claim as any)?.description ||
""
}}
</div>
<div>
<fa icon="user" class="fa-fw text-slate-400" />
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ didInfo(veriClaim.issuer) }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400" />
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
Recorded
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div>
<div v-if="veriClaim.claim.image" class="flex justify-center">
<a :href="veriClaim.claim.image" target="_blank">
<img :src="veriClaim.claim.image" class="h-24 rounded-xl" />
<div
v-if="(veriClaim.claim as any).image"
class="flex justify-center"
>
<a :href="(veriClaim.claim as any).image" target="_blank">
<img
:src="(veriClaim.claim as any).image"
class="h-24 rounded-xl"
/>
</a>
</div>
@@ -116,10 +131,10 @@
>
<!-- router-link to /claim/ only changes URL path -->
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="
showDifferentClaimPage(detailsForGive?.fulfillsHandleId)
"
class="text-blue-500 mt-4 cursor-pointer"
>
Fulfills
{{
@@ -155,15 +170,15 @@
<div class="flex gap-4">
<div class="grow overflow-hidden">
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="
provider.identifier.startsWith('did:')
? this.$router.push(
? $router.push(
'/did/' +
encodeURIComponent(provider.identifier),
)
: showDifferentClaimPage(provider.identifier)
"
class="text-blue-500 mt-4 cursor-pointer"
>
an activity...
</a>
@@ -177,13 +192,13 @@
</div>
</div>
<div class="mt-2">
<fa icon="comment" class="text-slate-400" />
<font-awesome icon="comment" class="text-slate-400" />
{{ issuerName }} posted that.
</div>
<!--
<div>
<router-link :to="'/claim-cert/' + encodeURIComponent(veriClaim.id)">
<fa icon="file-contract" class="text-slate-400" />
<font-awesome icon="file-contract" class="text-slate-400" />
<span class="ml-2 text-blue-500">Printable Certificate</span>
</router-link>
</div>
@@ -192,11 +207,14 @@
<div class="mt-8">
<button
v-if="libsUtil.canFulfillOffer(veriClaim)"
@click="openFulfillGiftDialog()"
class="col-span-1 block w-fit text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="openFulfillGiftDialog()"
>
Affirm Delivery
<fa icon="hand-holding-heart" class="ml-2 text-white cursor-pointer" />
<font-awesome
icon="hand-holding-heart"
class="ml-2 text-white cursor-pointer"
/>
</button>
</div>
<GiftedDialog ref="customGiveDialog" />
@@ -204,7 +222,6 @@
<div v-if="libsUtil.isGiveAction(veriClaim)">
<div class="flex columns-3">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
@@ -213,10 +230,14 @@
confirmerIdList,
)
"
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-4 py-2 rounded-md"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
<font-awesome
icon="circle-check"
class="ml-2 text-white cursor-pointer"
/>
</button>
<h2 v-else class="font-bold uppercase text-xl mt-2">Confirmations</h2>
@@ -276,7 +297,10 @@
target="_blank"
class="text-blue-500"
>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</span>
</div>
@@ -314,7 +338,10 @@
target="_blank"
class="text-blue-500"
>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</span>
</div>
@@ -339,14 +366,16 @@
</div>
</div>
<!-- Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue -->
<!--
Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue
-->
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
>
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" />
<fa v-else icon="chevron-right" />
<font-awesome v-if="showVeriClaimDump" icon="chevron-up" />
<font-awesome v-else icon="chevron-right" />
</h2>
<div v-if="showVeriClaimDump">
<div
@@ -361,7 +390,7 @@
<span v-if="canShare">
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a @click="onClickShareClaim()" class="text-blue-500"
<a class="text-blue-500" @click="onClickShareClaim()"
>click to send them this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -372,8 +401,8 @@
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -387,7 +416,7 @@
of your contacts.
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
<a class="text-blue-500" @click="onClickShareClaim()"
>click to share the information with them and ask if they'll tell
you more about the participants.</a
>
@@ -395,8 +424,8 @@
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -408,7 +437,7 @@
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw" />
<font-awesome icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
@@ -427,7 +456,10 @@
target="_blank"
class="text-blue-500"
>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
@@ -436,7 +468,7 @@
target="_blank"
class="text-blue-500"
>
<fa icon="globe" class="fa-fw" />
<font-awesome icon="globe" class="fa-fw" />
{{
veriClaim.publicUrls[visDid].substring(
veriClaim.publicUrls[visDid].indexOf("//") + 2,
@@ -476,7 +508,7 @@
class="text-blue-500 cursor-pointer"
@click="showFullClaim(veriClaim.id as string)"
>
<fa icon="file-lines" class="fa-fw" />
<font-awesome icon="file-lines" class="fa-fw" />
Load Full Claim Details
</button>
</div>
@@ -492,8 +524,8 @@
target="_blank"
class="text-blue-500 cursor-pointer"
>
<fa icon="file-lines" class="fa-fw" />
<fa icon="arrow-up-right-from-square" class="ml-1 fa-fw" />
<font-awesome icon="file-lines" class="fa-fw" />
<font-awesome icon="arrow-up-right-from-square" class="ml-1 fa-fw" />
View on the Public Server
</a>
</div>
@@ -505,9 +537,9 @@ import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -521,19 +553,18 @@ import * as serverUtil from "../libs/endorserServer";
import {
GenericCredWrapper,
OfferVerifiableCredential,
} from "../libs/endorserServer";
ProviderInfo,
} from "../interfaces";
import * as libsUtil from "../libs/util";
interface ProviderInfo {
identifier: string; // could be a DID or a handleId
linkConfirmed: boolean;
}
import { logger } from "../utils/logger";
@Component({
components: { GiftedDialog, QuickNav },
})
export default class ClaimView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
activeDid = "";
allMyDids: Array<string> = [];
@@ -544,8 +575,12 @@ export default class ClaimView extends Vue {
confirmerIdList: string[] = []; // list of DIDs that have confirmed this claim excluding the issuer
confsVisibleErrorMessage = "";
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
detailsForGive = null;
detailsForOffer = null;
detailsForGive: {
fulfillsPlanHandleId?: string;
fulfillsType?: string;
fulfillsHandleId?: string;
} | null = null;
detailsForOffer: { fulfillsPlanHandleId?: string } | null = null;
fullClaim = null;
fullClaimDump = "";
fullClaimMessage = "";
@@ -558,7 +593,7 @@ export default class ClaimView extends Vue {
showVeriClaimDump = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href;
R = R;
@@ -585,6 +620,7 @@ export default class ClaimView extends Vue {
}
async created() {
logger.log("ClaimView created");
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
@@ -594,7 +630,6 @@ export default class ClaimView extends Vue {
try {
this.allMyDids = await libsUtil.retrieveAccountDids();
} catch (error) {
// continue because we want to see claims, even anonymously
logConsoleAndDb(
"Error retrieving all account DIDs on home page:" + error,
true,
@@ -610,10 +645,8 @@ export default class ClaimView extends Vue {
);
}
const pathParam = window.location.pathname.substring("/claim/".length);
let claimId;
if (pathParam) {
claimId = decodeURIComponent(pathParam);
const claimId = this.$route.params.id as string;
if (claimId) {
await this.loadClaim(claimId, this.activeDid);
} else {
this.$notify(
@@ -627,8 +660,6 @@ export default class ClaimView extends Vue {
);
}
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share;
}
@@ -659,6 +690,7 @@ export default class ClaimView extends Vue {
}
async loadClaim(claimId: string, userDid: string) {
logger.log("[ClaimView] loadClaim called with claimId:", claimId);
const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/"
: "/api/claim/";
@@ -666,6 +698,7 @@ export default class ClaimView extends Vue {
const headers = await serverUtil.getHeaders(userDid);
try {
logger.log("[ClaimView] Making API request to:", url);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
this.veriClaim = resp.data;
@@ -677,7 +710,7 @@ export default class ClaimView extends Vue {
);
} else {
// actually, axios typically throws an error so we never get here
console.error("Error getting claim:", resp);
logger.error("Error getting claim:", resp);
this.$notify(
{
group: "alert",
@@ -705,7 +738,7 @@ export default class ClaimView extends Vue {
if (giveResp.status === 200 && giveResp.data.data?.length > 0) {
this.detailsForGive = giveResp.data.data[0];
} else {
console.error("Error getting detailed give info:", giveResp);
logger.error("Error getting detailed give info:", giveResp);
}
// look for providers
@@ -724,7 +757,7 @@ export default class ClaimView extends Vue {
) {
this.providersForGive = providerResp.data.data;
} else {
console.error("Error getting give providers:", giveResp);
logger.error("Error getting give providers:", giveResp);
this.$notify(
{
group: "alert",
@@ -747,7 +780,7 @@ export default class ClaimView extends Vue {
if (offerResp.status === 200) {
this.detailsForOffer = offerResp.data.data[0];
} else {
console.error("Error getting detailed offer info:", offerResp);
logger.error("Error getting detailed offer info:", offerResp);
this.$notify(
{
group: "alert",
@@ -777,7 +810,7 @@ export default class ClaimView extends Vue {
}
} catch (error: unknown) {
const serverError = error as AxiosError;
console.error("Error retrieving claim:", serverError);
logger.error("Error retrieving claim:", serverError);
this.$notify(
{
group: "alert",
@@ -802,7 +835,7 @@ export default class ClaimView extends Vue {
this.fullClaimDump = yaml.dump(this.fullClaim);
} else {
// actually, axios typically throws an error so we never get here
console.error("Error getting full claim:", resp);
logger.error("Error getting full claim:", resp);
this.$notify(
{
group: "alert",
@@ -814,7 +847,7 @@ export default class ClaimView extends Vue {
);
}
} catch (error: unknown) {
console.error("Error retrieving full claim:", error);
logger.error("Error retrieving full claim:", error);
const serverError = error as AxiosError;
if (serverError.response?.status === 403) {
let issuerPhrase = "";
@@ -887,7 +920,7 @@ export default class ClaimView extends Vue {
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
const confirmationClaim: GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
@@ -909,7 +942,7 @@ export default class ClaimView extends Vue {
5000,
);
} else {
console.error("Got error submitting the confirmation:", result);
logger.error("Got error submitting the confirmation:", result);
this.$notify(
{
group: "alert",
@@ -999,7 +1032,7 @@ export default class ClaimView extends Vue {
};
(this.$router as Router).push(route);
} else {
console.error(
logger.error(
"Unrecognized claim type for edit:",
this.veriClaim.claimType,
);

View File

@@ -8,8 +8,8 @@
<router-link
:to="{ name: 'account' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa
></router-link>
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Confirm Contact
</h1>

View File

@@ -8,10 +8,10 @@
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<fa icon="chevron-left" class="fa-fw" />
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
<span
v-if="
@@ -32,7 +32,6 @@
<div v-if="giveDetails && !isLoading">
<div class="flex justify-center">
<button
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
@@ -41,18 +40,25 @@
confirmerIdList,
)
"
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="confirmConfirmClaim()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
<font-awesome
icon="circle-check"
class="ml-2 text-white cursor-pointer"
/>
</button>
<button
v-else
@click="notifyWhyCannotConfirm()"
class="col-span-1 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-4 py-2 rounded-md"
@click="notifyWhyCannotConfirm()"
>
Confirm
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
<font-awesome
icon="circle-check"
class="ml-2 text-white cursor-pointer"
/>
</button>
</div>
@@ -62,26 +68,29 @@
<div class="overflow-hidden">
<div class="text-sm">
<div>
<fa icon="arrow-left" class="fa-fw text-slate-400" />
<font-awesome icon="arrow-left" class="fa-fw text-slate-400" />
{{ giverName }}
</div>
<div class="ml-6">gave</div>
<div v-if="giveDetails.amount">
<fa icon="hand-holding-dollar" class="fa-fw text-slate-400" />
<font-awesome
icon="hand-holding-dollar"
class="fa-fw text-slate-400"
/>
{{ displayAmount(giveDetails.unit, giveDetails.amount) }}
</div>
<div v-if="giveDetails.description">
<fa icon="message" class="fa-fw text-slate-400" />
<font-awesome icon="message" class="fa-fw text-slate-400" />
{{ giveDetails.amount ? "and:" : "" }}
{{ giveDetails.description }}
</div>
<div class="ml-6">to</div>
<div>
<fa icon="arrow-right" class="fa-fw text-slate-400" />
<font-awesome icon="arrow-right" class="fa-fw text-slate-400" />
{{ recipientName }}
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400" />
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
on
{{ giveDetails.issuedAt.substring(0, 10) }}
</div>
@@ -89,7 +98,7 @@
<!-- Fullfills Links -->
<!-- fullfills links for a give -->
<div class="mt-2" v-if="giveDetails?.fulfillsPlanHandleId">
<div v-if="giveDetails?.fulfillsPlanHandleId" class="mt-2">
<router-link
:to="
'/project/' +
@@ -99,7 +108,10 @@
target="_blank"
>
This fulfills a bigger plan
<fa icon="arrow-up-right-from-square" class="fa-fw" />
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
@@ -125,7 +137,10 @@
giveDetails?.fulfillsType || "",
)
}}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
</div>
@@ -133,7 +148,7 @@
</div>
</div>
<div class="mt-2">
<fa icon="comment" class="text-slate-400" />
<font-awesome icon="comment" class="text-slate-400" />
{{ issuerName }} posted that.
</div>
@@ -185,7 +200,10 @@
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
/>
</button>
</span>
</div>
@@ -228,7 +246,10 @@
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
/>
</button>
</span>
</div>
@@ -254,14 +275,16 @@
</div>
</div>
<!-- Note that a similar section is found in ClaimView.vue, and kinda in HiddenDidDialog.vue -->
<!--
Note that a similar section is found in ClaimView.vue, and kinda in HiddenDidDialog.vue
-->
<h2
class="font-bold uppercase text-xl text-blue-500 mt-8 cursor-pointer"
@click="showVeriClaimDump = !showVeriClaimDump"
>
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" />
<fa v-else icon="chevron-right" />
<font-awesome v-if="showVeriClaimDump" icon="chevron-up" />
<font-awesome v-else icon="chevron-right" />
</h2>
<div v-if="showVeriClaimDump">
<div
@@ -276,7 +299,7 @@
<span v-if="canShare">
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a @click="onClickShareClaim()" class="text-blue-500"
<a class="text-blue-500" @click="onClickShareClaim()"
>click to send them this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -287,8 +310,8 @@
You can ask one of your contacts to take a look and see if their
contacts can see more details:
<a
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -302,7 +325,7 @@
some of your contacts.
<span v-if="canShare">
If you'd like an introduction,
<a @click="onClickShareClaim()" class="text-blue-500"
<a class="text-blue-500" @click="onClickShareClaim()"
>click to share the information with them and ask if they'll tell
you more about the participants.</a
>
@@ -310,8 +333,8 @@
<span v-else>
If you'd like an introduction,
<a
@click="copyToClipboard('A link to this page', windowLocation)"
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -323,7 +346,7 @@
class="list-disc p-4"
>
<div class="text-sm">
<fa icon="minus" class="fa-fw" />
<font-awesome icon="minus" class="fa-fw" />
The {{ visibleDidPath }} is visible to:
</div>
<div class="ml-12 p-1">
@@ -342,12 +365,18 @@
copyToClipboard('The DID of ' + visDid, visDid)
"
>
<fa icon="copy" class="text-slate-400 fa-fw" />
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
/>
</button>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at
<fa icon="globe" class="fa-fw text-slate-400" />
<font-awesome
icon="globe"
class="fa-fw text-slate-400"
/>
<a
:href="veriClaim.publicUrls?.[visDid]"
class="text-blue-500"
@@ -372,10 +401,10 @@
>
<div class="mt-2 ml-2">
<a
@click="showClaimPage(veriClaim.id)"
class="text-blue-500 cursor-pointer"
@click="showClaimPage(veriClaim.id)"
>
<fa icon="file-lines" />
<font-awesome icon="file-lines" />
See All Generic Info
</a>
</div>
@@ -385,7 +414,7 @@
class="text-blue-500 cursor-pointer"
:href="urlForNewGive"
>
<fa icon="file-lines" />
<font-awesome icon="file-lines" />
Record a Give Similar to the Original
</a>
</div>
@@ -394,38 +423,55 @@
<div v-else-if="!isLoading">This does not have details to confirm.</div>
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
<font-awesome icon="spinner" class="fa-spin-pulse"></font-awesome>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer";
import { displayAmount, GiveSummaryRecord } from "../libs/endorserServer";
import { GenericVerifiableCredential, GiveSummaryRecord } from "../interfaces";
import { displayAmount } from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { isGiveAction, retrieveAccountDids } from "../libs/util";
import { retrieveAccountDids } from "../libs/util";
import TopMessage from "../components/TopMessage.vue";
import { logger } from "../utils/logger";
/**
* ConfirmGiftView Component
*
* Displays details about a gift claim and allows users to confirm it if eligible.
* Shows gift details including giver, recipient, amount, description, and confirmation status.
* Handles visibility of hidden DIDs and provides access to detailed claim information.
*
* Key features:
* - Gift confirmation workflow
* - Detailed gift information display
* - Confirmation status tracking
* - Hidden DID handling
* - Claim details expansion
*/
@Component({
methods: { displayAmount },
components: { TopMessage, QuickNav },
components: {
QuickNav,
TopMessage,
},
})
export default class ClaimView extends Vue {
export default class ConfirmGiftView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
activeDid = "";
allMyDids: Array<string> = [];
@@ -447,102 +493,104 @@ export default class ClaimView extends Vue {
urlForNewGive = "";
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href;
R = R;
yaml = yaml;
libsUtil = libsUtil;
serverUtil = serverUtil;
displayAmount = displayAmount;
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.giveDetails = undefined;
this.isRegistered = false;
this.numConfsNotVisible = 0;
this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
/**
* Initializes the view with gift claim information
*
* Workflow:
* 1. Retrieves active account settings
* 2. Loads gift claim details from ID in URL
* 3. Processes claim information for display
* 4. Checks user's ability to confirm the gift
*/
async mounted() {
this.isLoading = true;
try {
await this.initializeSettings();
await this.loadClaimFromUrl();
} catch (error) {
logger.error("Error in mounted:", error);
this.handleMountError(error);
} finally {
this.isLoading = false;
}
}
/**
* Initializes component settings and user data
*/
private async initializeSettings() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.allContacts = await db.contacts.toArray();
this.isRegistered = settings.isRegistered || false;
this.allMyDids = await retrieveAccountDids();
const pathParam = window.location.pathname.substring(
"/confirm-gift/".length,
);
let claimId;
if (pathParam) {
claimId = decodeURIComponent(pathParam);
await this.loadClaim(claimId, this.activeDid);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "No claim ID was provided.",
},
3000,
);
}
// Check share capability
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share;
this.isLoading = false;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
/**
* Loads and processes claim from URL parameters
*/
private async loadClaimFromUrl() {
const pathParam = window.location.pathname.substring(
"/confirm-gift/".length,
);
if (!pathParam) {
throw new Error("No claim ID was provided.");
}
const claimId = decodeURIComponent(pathParam);
await this.loadClaim(claimId, this.activeDid);
}
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string) {
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
/**
* Handles errors during component mounting
*/
private handleMountError(error: unknown) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
error instanceof Error ? error.message : "No claim ID was provided.",
},
3000,
);
}
/**
* Loads claim details and associated give information
*
* @param claimId - ID of claim to load
* @param userDid - User's DID
*/
private async loadClaim(claimId: string, userDid: string) {
await this.fetchClaimDetails(claimId, userDid);
if (this.veriClaim.claimType === "GiveAction") {
await this.fetchGiveDetails(claimId, userDid);
await this.processGiveDetails();
await this.fetchConfirmerInfo(claimId, userDid);
}
}
totalConfirmers() {
return (
this.numConfsNotVisible +
this.confirmerIdList.length +
this.confsVisibleToIdList.length
);
}
// Isn't there a better way to make this available to the template?
didInfo(did: string | undefined) {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
async loadClaim(claimId: string, userDid: string) {
/**
* Fetches basic claim details from server
*/
private async fetchClaimDetails(claimId: string, userDid: string) {
const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/"
: "/api/claim/";
@@ -551,9 +599,7 @@ export default class ClaimView extends Vue {
try {
const headers = await serverUtil.getHeaders(userDid);
const resp = await this.axios.get(url, { headers });
// resp.data is:
// - a Jwt from https://api.endorser.ch/api-docs/
// - with a Give from https://endorser.ch/doc/html/transactions.html#id3
if (resp.status === 200) {
this.veriClaim = resp.data;
this.veriClaimDump = yaml.dump(this.veriClaim);
@@ -561,138 +607,180 @@ export default class ClaimView extends Vue {
this.veriClaim,
true,
);
this.issuerName = this.didInfo(this.veriClaim.issuer);
} else {
// actually, axios typically throws an error so we never get here
console.error("Error getting claim:", resp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem retrieving that claim.",
},
3000,
);
return;
throw new Error("Error getting claim: " + resp.status);
}
// retrieve more details on Give, Offer, or Plan
if (this.veriClaim.claimType !== "GiveAction") {
// no need to go further... this page is for gifts
return;
}
this.issuerName = this.didInfo(this.veriClaim.issuer);
// use give record when possible since it may include edits
const giveUrl =
this.apiServer +
"/api/v2/report/gives?handleId=" +
encodeURIComponent(this.veriClaim.handleId as string);
const giveHeaders = await serverUtil.getHeaders(userDid);
const giveResp = await this.axios.get(giveUrl, {
headers: giveHeaders,
});
// giveResp.data is a Give from https://api.endorser.ch/api-docs/
if (giveResp.status === 200) {
this.giveDetails = giveResp.data.data[0];
} else {
console.error("Error getting detailed give info:", giveResp);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving gift data.",
},
3000,
);
return;
}
// the logic already stops earlier if the claim doesn't exist but this helps with typechecking
if (!this.giveDetails) {
return;
}
this.urlForNewGive = "/gifted-details?";
if (this.giveDetails.amount) {
this.urlForNewGive +=
"&amountInput=" + encodeURIComponent(String(this.giveDetails.amount));
}
if (this.giveDetails.unit) {
this.urlForNewGive +=
"&unitCode=" + encodeURIComponent(this.giveDetails.unit);
}
if (this.giveDetails.description) {
this.urlForNewGive +=
"&description=" + encodeURIComponent(this.giveDetails.description);
}
this.giverName = this.didInfo(this.giveDetails.agentDid);
if (this.giveDetails.agentDid) {
this.urlForNewGive +=
"&giverDid=" +
encodeURIComponent(this.giveDetails.agentDid) +
"&giverName=" +
encodeURIComponent(this.giverName);
}
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
if (this.giveDetails.recipientDid) {
this.urlForNewGive +=
"&recipientDid=" +
encodeURIComponent(this.giveDetails.recipientDid) +
"&recipientName=" +
encodeURIComponent(this.recipientName);
}
if (this.giveDetails.fullClaim.image) {
this.urlForNewGive +=
"&image=" + encodeURIComponent(this.giveDetails.fullClaim.image);
}
if (
this.giveDetails.type == "Offer" &&
this.giveDetails.fulfillsHandleId
) {
this.urlForNewGive +=
"&offerId=" +
encodeURIComponent(this.giveDetails?.fulfillsHandleId as string);
}
if (this.giveDetails.fulfillsPlanHandleId) {
this.urlForNewGive +=
"&fulfillsProjectId=" +
encodeURIComponent(this.giveDetails.fulfillsPlanHandleId);
}
// retrieve the list of confirmers
const confirmerInfo = await libsUtil.retrieveConfirmerIdList(
this.apiServer,
claimId,
this.veriClaim.issuer,
userDid,
);
if (confirmerInfo) {
this.confirmerIdList = confirmerInfo.confirmerIdList;
this.confsVisibleToIdList = confirmerInfo.confsVisibleToIdList;
this.numConfsNotVisible = confirmerInfo.numConfsNotVisible;
} else {
this.confsVisibleErrorMessage =
"Had problems retrieving confirmations.";
}
} catch (error: unknown) {
const serverError = error as AxiosError;
console.error("Error retrieving claim:", serverError);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Something went wrong retrieving claim data.",
},
3000,
);
} catch (error) {
logger.error("Error getting claim:", error);
throw new Error("There was a problem retrieving that claim.");
}
}
confirmConfirmClaim() {
/**
* Fetches detailed give information
*/
private async fetchGiveDetails(claimId: string, userDid: string) {
const param = libsUtil.isGlobalUri(claimId) ? "handleId" : "jwtId";
const giveUrl = `${this.apiServer}/api/v2/report/gives?${param}=${encodeURIComponent(claimId)}`;
try {
const headers = await serverUtil.getHeaders(userDid);
const resp = await this.axios.get(giveUrl, { headers });
if (resp.status === 200) {
this.giveDetails = resp.data.data[0];
} else {
throw new Error("Error getting detailed give info: " + resp.status);
}
} catch (error) {
logger.error("Error getting detailed give info:", error);
throw new Error("Something went wrong retrieving gift data.");
}
}
/**
* Processes give details and builds URL for new give
*/
private async processGiveDetails() {
if (!this.giveDetails) return;
this.urlForNewGive = "/gifted-details?";
/**
* Add basic give details to URL
*/
if (this.giveDetails?.amount) {
this.urlForNewGive += `&amountInput=${encodeURIComponent(String(this.giveDetails.amount))}`;
}
if (this.giveDetails?.unit) {
this.urlForNewGive += `&unitCode=${encodeURIComponent(this.giveDetails.unit)}`;
}
if (this.giveDetails?.description) {
this.urlForNewGive += `&description=${encodeURIComponent(this.giveDetails.description)}`;
}
/**
* Add participant (giver/recipient) name & URL info
*/
if (this.giveDetails?.agentDid) {
this.giverName = this.didInfo(this.giveDetails.agentDid);
this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`;
}
if (this.giveDetails?.recipientDid) {
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
}
/**
* Add additional give details to URL (image, offer, plan)
*/
if (this.giveDetails?.fullClaim.image) {
this.urlForNewGive += `&image=${encodeURIComponent(this.giveDetails.fullClaim.image)}`;
}
if (
this.giveDetails?.type === "Offer" &&
this.giveDetails?.fulfillsHandleId
) {
this.urlForNewGive += `&offerId=${encodeURIComponent(this.giveDetails.fulfillsHandleId)}`;
}
if (this.giveDetails?.fulfillsPlanHandleId) {
this.urlForNewGive += `&fulfillsProjectId=${encodeURIComponent(this.giveDetails.fulfillsPlanHandleId)}`;
}
}
/**
* Fetches confirmer information for the claim
*/
private async fetchConfirmerInfo(claimId: string, userDid: string) {
const confirmerInfo = await libsUtil.retrieveConfirmerIdList(
this.apiServer,
claimId,
this.veriClaim.issuer,
userDid,
);
if (confirmerInfo) {
this.confirmerIdList = confirmerInfo.confirmerIdList;
this.confsVisibleToIdList = confirmerInfo.confsVisibleToIdList;
this.numConfsNotVisible = confirmerInfo.numConfsNotVisible;
} else {
this.confsVisibleErrorMessage = "Had problems retrieving confirmations.";
}
}
/**
* Calculates total number of confirmers for the gift
* Includes both direct confirmers and those visible through network
*
* @returns Total number of confirmers
*/
totalConfirmers(): number {
return (
this.numConfsNotVisible +
this.confirmerIdList.length +
this.confsVisibleToIdList.length
);
}
/**
* Retrieves human-readable name for a DID
* Falls back to DID if no name available
*
* @param did - DID to get name for
* @returns Human-readable name
*/
didInfo(did: string): string {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Copies text to clipboard and shows notification
*
* @param description - Description of copied content
* @param text - Text to copy
*/
copyToClipboard(description: string, text: string): void {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (description || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
/**
* Navigates to claim page for detailed view
*
* @param claimId - ID of claim to view
*/
showClaimPage(claimId: string): void {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
(this.$router as Router).push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, this.activeDid);
});
}
/**
* Initiates claim confirmation process
* Verifies user eligibility and handles confirmation workflow
*/
async confirmConfirmClaim(): Promise<void> {
this.$notify(
{
group: "modal",
@@ -719,7 +807,7 @@ export default class ClaimView extends Vue {
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
const confirmationClaim: GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
@@ -754,33 +842,11 @@ export default class ClaimView extends Vue {
}
}
showClaimPage(claimId: string) {
const route = {
path: "/claim/" + encodeURIComponent(claimId),
};
(this.$router as Router).push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, this.activeDid);
});
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: (name || "That") + " was copied to the clipboard.",
},
2000,
);
});
}
notifyWhyCannotConfirm() {
/**
* Notifies user why they cannot confirm the gift
* Explains requirements or restrictions preventing confirmation
*/
notifyWhyCannotConfirm(): void {
libsUtil.notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
@@ -791,71 +857,32 @@ export default class ClaimView extends Vue {
);
}
notifyWhyCannotConfirmBak() {
if (!this.isRegistered) {
this.$notify(
{
group: "alert",
type: "info",
title: "Not Registered",
text: "Someone needs to register you before you can contribute.",
},
3000,
);
} else if (!isGiveAction(this.veriClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Not A Give",
text: "This is not a giving action to confirm.",
},
3000,
);
} else if (this.confirmerIdList.includes(this.activeDid)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Already Confirmed",
text: "You already confirmed this claim.",
},
3000,
);
} else if (this.giveDetails?.issuerDid == this.activeDid) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because you issued this claim.",
},
3000,
);
} else if (serverUtil.containsHiddenDid(this.giveDetails?.fullClaim)) {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this because some people are hidden.",
},
3000,
);
/**
* Formats type string for display by adding spaces before capitals
* Optionally adds a prefix
*
* @param text - Text to format
* @param prefix - Optional prefix to add
* @returns Formatted string
*/
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string): string {
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
this.$notify(
{
group: "alert",
type: "info",
title: "Cannot Confirm",
text: "You cannot confirm this claim. There are no other details, but we can help more if you contact us and send us screenshots.",
},
3000,
);
return "";
}
}
onClickShareClaim() {
/**
* Initiates sharing of claim information
* Handles share functionality based on platform capabilities
*/
async onClickShareClaim(): Promise<void> {
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
@@ -863,5 +890,23 @@ export default class ClaimView extends Vue {
url: this.windowLocation,
});
}
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.giveDetails = undefined;
this.isRegistered = false;
this.numConfsNotVisible = 0;
this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
}
</script>

View File

@@ -11,8 +11,8 @@
<router-link
:to="{ name: 'contacts' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa
></router-link>
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
</h1>
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
@@ -59,10 +59,13 @@
<div class="font-bold">
{{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
<font-awesome
icon="circle-check"
class="text-green-600 fa-fw"
/>
</span>
<button v-else @click="confirm(record)" title="Unconfirmed">
<fa icon="circle" class="text-blue-600 fa-fw" />
<button v-else title="Unconfirmed" @click="confirm(record)">
<font-awesome icon="circle" class="text-blue-600 fa-fw" />
</button>
</div>
<div class="italic text-xs sm:text-sm text-slate-500">
@@ -72,10 +75,10 @@
</td>
<td class="p-1">
<span v-if="record.agentDid == contact?.did">
<fa icon="arrow-left" class="text-slate-400 fa-fw" />
<font-awesome icon="arrow-left" class="text-slate-400 fa-fw" />
</span>
<span v-else>
<fa icon="arrow-right" class="text-slate-400 fa-fw" />
<font-awesome icon="arrow-right" class="text-slate-400 fa-fw" />
</span>
</td>
<td class="p-1">
@@ -83,14 +86,17 @@
<div class="font-bold">
{{ displayAmount(record.unit, record.amount) }}
<span v-if="record.amountConfirmed" title="Confirmed">
<fa icon="circle-check" class="text-green-600 fa-fw" />
<font-awesome
icon="circle-check"
class="text-green-600 fa-fw"
/>
</span>
<button
v-else
@click="cannotConfirmMessage()"
title="Unconfirmed"
@click="cannotConfirmMessage()"
>
<fa icon="circle" class="text-slate-600 fa-fw" />
<font-awesome icon="circle" class="text-slate-600 fa-fw" />
</button>
</div>
<div class="italic text-xs sm:text-sm text-slate-500">
@@ -108,7 +114,7 @@
import { AxiosError, AxiosRequestHeaders } from "axios";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -116,18 +122,22 @@ import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import {
AgreeVerifiableCredential,
GiveSummaryRecord,
GiveVerifiableCredential,
} from "../interfaces";
import {
createEndorserJwtVcFromClaim,
displayAmount,
getHeaders,
GiveSummaryRecord,
GiveVerifiableCredential,
SCHEMA_ORG_CONTEXT,
} from "../libs/endorserServer";
import { retrieveAccountCount } from "../libs/util";
import { logger } from "../utils/logger";
@Component({ components: { QuickNav } })
export default class ContactAmountssView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
activeDid = "";
apiServer = "";
@@ -143,7 +153,7 @@ export default class ContactAmountssView extends Vue {
async created() {
try {
const contactDid = (this.$route as Router).query["contactDid"] as string;
const contactDid = this.$route.query["contactDid"] as string;
this.contact = (await db.contacts.get(contactDid)) || null;
const settings = await retrieveSettingsForActiveAccount();
@@ -155,7 +165,7 @@ export default class ContactAmountssView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings or gives.", err);
logger.error("Error retrieving settings or gives.", err);
this.$notify(
{
group: "alert",
@@ -184,7 +194,7 @@ export default class ContactAmountssView extends Vue {
if (resp.status === 200) {
result = resp.data.data;
} else {
console.error(
logger.error(
"Got bad response status & data of",
resp.status,
resp.data,
@@ -211,7 +221,7 @@ export default class ContactAmountssView extends Vue {
if (resp2.status === 200) {
result = R.concat(result, resp2.data.data);
} else {
console.error(
logger.error(
"Got bad response status & data of",
resp2.status,
resp2.data,

View File

@@ -7,10 +7,10 @@
<h1 class="text-4xl text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<fa icon="chevron-left" class="fa-fw" />
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
{{ contact.name || AppString.NO_CONTACT_NAME }}
</h1>
@@ -25,9 +25,9 @@
Name
</label>
<input
v-model="contactName"
type="text"
class="block w-full ml-2 mt-1 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
v-model="contactName"
/>
</div>
@@ -38,9 +38,9 @@
</label>
<textarea
id="contactNotes"
v-model="contactNotes"
rows="4"
class="block w-full mt-1 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
v-model="contactNotes"
></textarea>
</div>
@@ -53,60 +53,60 @@
class="flex mt-2"
>
<input
type="text"
v-model="method.label"
type="text"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
type="text"
v-model="method.type"
type="text"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
@click="toggleDropdown(index)"
class="px-2 py-1 bg-gray-200 rounded-md"
@click="toggleDropdown(index)"
>
<fa icon="caret-down" class="fa-fw" />
<font-awesome icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
@click="setMethodType(index, 'CELL')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'CELL')"
>
CELL
</div>
<div
@click="setMethodType(index, 'EMAIL')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'EMAIL')"
>
EMAIL
</div>
<div
@click="setMethodType(index, 'WHATSAPP')"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'WHATSAPP')"
>
WHATSAPP
</div>
</div>
</div>
<input
type="text"
v-model="method.value"
type="text"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button @click="removeContactMethod(index)" class="ml-2 text-red-500">
<fa icon="trash-can" class="fa-fw" />
<button class="ml-2 text-red-500" @click="removeContactMethod(index)">
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<button @click="addContactMethod" class="mt-2">
<fa
<button class="mt-2" @click="addContactMethod">
<font-awesome
icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
/>
@@ -134,7 +134,7 @@
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocation, Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
@@ -142,6 +142,37 @@ import { AppString, NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { Contact, ContactMethod } from "../db/tables/contacts";
/**
* Contact Edit View Component
* @author Matthew Raymer
*
* This component provides a full-featured contact editing interface with support for:
* - Basic contact information (name, notes)
* - Multiple contact methods with type selection
* - Data validation and persistence
*
* Workflow:
* 1. Component loads with DID from route params
* 2. Fetches existing contact data from IndexedDB
* 3. Presents editable form with current values
* 4. Validates and saves updates back to database
*
* Contact Method Types:
* - CELL: Mobile phone numbers
* - EMAIL: Email addresses
* - WHATSAPP: WhatsApp contact info
*
* State Management:
* - Maintains separate state for form fields to prevent direct mutation
* - Handles array cloning for contact methods to prevent reference issues
* - Manages dropdown state for method type selection
*
* Navigation:
* - Back button returns to previous view
* - Save redirects to contact detail view
* - Cancel returns to previous view
* - Invalid DID redirects to contacts list
*/
@Component({
components: {
QuickNav,
@@ -149,22 +180,46 @@ import { Contact, ContactMethod } from "../db/tables/contacts";
},
})
export default class ContactEditView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void;
/** Current route instance */
$route!: RouteLocationNormalizedLoaded;
/** Router instance for navigation */
$router!: Router;
/** Current contact data */
contact: Contact = {
did: "",
name: "",
notes: "",
};
/** Editable contact name field */
contactName = "";
/** Editable contact notes field */
contactNotes = "";
/** Array of editable contact methods */
contactMethods: Array<ContactMethod> = [];
/** Currently open dropdown index, null if none open */
dropdownIndex: number | null = null;
/** App string constants */
AppString = AppString;
/**
* Component lifecycle hook that initializes the contact edit form
*
* Workflow:
* 1. Extracts DID from route parameters
* 2. Queries database for existing contact
* 3. Populates form fields with contact data
* 4. Handles missing contact error case
*
* @throws Will not throw but redirects on error
* @emits Notification on contact not found
* @emits Router navigation on error
*/
async created() {
const contactDid = (this.$route as RouteLocation).params.did;
const contactDid = this.$route.params.did;
const contact = await db.contacts.get(contactDid || "");
if (contact) {
this.contact = contact;
@@ -183,29 +238,75 @@ export default class ContactEditView extends Vue {
}
}
/**
* Adds a new empty contact method to the methods array
*
* Creates a new method object with empty fields for:
* - label: Custom label for the method
* - type: Communication type (CELL, EMAIL, WHATSAPP)
* - value: The contact information value
*/
addContactMethod() {
this.contactMethods.push({ label: "", type: "", value: "" });
}
/**
* Removes a contact method at the specified index
*
* @param index The array index of the method to remove
*/
removeContactMethod(index: number) {
this.contactMethods.splice(index, 1);
}
/**
* Toggles the type selection dropdown for a contact method
*
* If the clicked dropdown is already open, closes it.
* If another dropdown is open, closes it and opens the clicked one.
*
* @param index The array index of the method whose dropdown to toggle
*/
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
/**
* Sets the type for a contact method and closes the dropdown
*
* @param index The array index of the method to update
* @param type The new type value (CELL, EMAIL, WHATSAPP)
*/
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
/**
* Saves the edited contact information to the database
*
* Workflow:
* 1. Clones contact methods array to prevent reference issues
* 2. Normalizes method types to uppercase
* 3. Checks for changes in method types
* 4. Updates database with new values
* 5. Notifies user of success
* 6. Redirects to contact detail view
*
* @throws Will not throw but notifies on validation errors
* @emits Notification on type changes or success
* @emits Router navigation on success
*/
async saveEdit() {
// without this conversion, "Failed to execute 'put' on 'IDBObjectStore': [object Array] could not be cloned."
const contactMethodsObj = JSON.parse(JSON.stringify(this.contactMethods));
// Normalize method types to uppercase
const contactMethods = contactMethodsObj.map((method: ContactMethod) =>
R.set(R.lensProp("type"), method.type.toUpperCase(), method),
);
// Check for type changes
if (!R.equals(contactMethodsObj, contactMethods)) {
this.contactMethods = contactMethods;
this.$notify(
@@ -219,11 +320,15 @@ export default class ContactEditView extends Vue {
);
return;
}
// Save to database
await db.contacts.update(this.contact.did, {
name: this.contactName,
notes: this.contactNotes,
contactMethods: contactMethods,
});
// Notify success and redirect
this.$notify({
group: "alert",
type: "success",

View File

@@ -9,8 +9,8 @@
<router-link
:to="{ name: 'home' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa
></router-link>
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Given by...
</h1>
</div>
@@ -30,10 +30,10 @@
<span class="text-right">
<button
type="button"
@click="openDialog()"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="openDialog()"
>
<fa icon="gift" class="fa-fw"></fa>
<font-awesome icon="gift" class="fa-fw"></font-awesome>
</button>
</span>
</h2>
@@ -47,7 +47,7 @@
<span class="grow font-semibold">
<EntityIcon
:contact="contact"
:iconSize="32"
:icon-size="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
{{ contact.name || "(no name)" }}
@@ -55,23 +55,23 @@
<span class="text-right">
<button
type="button"
@click="openDialog(contact)"
class="block w-full text-center text-sm uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="openDialog(contact)"
>
<fa icon="gift" class="fa-fw"></fa>
<font-awesome icon="gift" class="fa-fw"></font-awesome>
</button>
</span>
</h2>
</li>
</ul>
<GiftedDialog ref="customDialog" :toProjectId="projectId" />
<GiftedDialog ref="customDialog" :to-project-id="projectId" />
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
@@ -80,12 +80,14 @@ import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { GiverReceiverInputInfo } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
})
export default class ContactGiftingView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
activeDid = "";
allContacts: Array<Contact> = [];
@@ -107,13 +109,12 @@ export default class ContactGiftingView extends Vue {
(a.name || "").localeCompare(b.name || ""),
);
this.projectId = (this.$route as Router).query["projectId"] || "";
this.prompt = (this.$route as Router).query["prompt"] ?? this.prompt;
this.projectId = (this.$route.query["projectId"] as string) || "";
this.prompt = (this.$route.query["prompt"] as string) ?? this.prompt;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings & contacts:", err);
logger.error("Error retrieving settings & contacts:", err);
this.$notify(
{
group: "alert",

View File

@@ -7,7 +7,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -17,14 +17,14 @@
</h1>
<div v-if="checkingImports" class="text-center">
<fa icon="spinner" class="animate-spin" />
<font-awesome icon="spinner" class="animate-spin" />
</div>
<div v-else>
<span
v-if="contactsImporting.length > sameCount"
class="flex justify-center"
>
<input type="checkbox" v-model="makeVisible" class="mr-2" />
<input v-model="makeVisible" type="checkbox" class="mr-2" />
Make my activity visible to these contacts.
</span>
@@ -51,7 +51,7 @@
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
<input v-model="contactsSelected[index]" type="checkbox" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
@@ -110,8 +110,8 @@
/>
<br />
<button
@click="() => processContactJwt(inputJwt)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
@click="() => processContactJwt(inputJwt)"
>
Check Import
</button>
@@ -122,6 +122,77 @@
</template>
<script lang="ts">
/**
* @file Contact Import View Component
* @author Matthew Raymer
*
* This component handles the import of contacts into the TimeSafari app.
* It supports multiple import methods and handles duplicate detection,
* contact validation, and visibility settings.
*
* Import Methods:
* 1. Direct URL Query Parameters:
* Example: /contact-import?contacts=[{"did":"did:example:123","name":"Alice"}]
*
* 2. JWT in URL Path:
* Example: /contact-import/eyJhbGciOiJFUzI1NksifQ...
* - Supports both single and bulk imports
* - JWT payload can be either:
* a) Array format: { contacts: [{did: "...", name: "..."}, ...] }
* b) Single contact: { own: true, did: "...", name: "..." }
*
* 3. Manual JWT Input:
* - Accepts pasted JWT strings
* - Validates format and content before processing
*
* URL Examples:
* ```
* # Bulk import via query params
* /contact-import?contacts=[
* {"did":"did:example:123","name":"Alice"},
* {"did":"did:example:456","name":"Bob"}
* ]
*
* # Single contact via JWT
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJvd24iOnRydWUsImRpZCI6ImRpZDpleGFtcGxlOjEyMyJ9...
*
* # Bulk import via JWT
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJjb250YWN0cyI6W3siZGlkIjoiZGlkOmV4YW1wbGU6MTIzIn1dfQ...
*
* # Redirect to contacts page (single contact)
* /contacts?contactJwt=eyJhbGciOiJFUzI1NksifQ...
* ```
*
* Features:
* - Automatic duplicate detection
* - Field-by-field comparison for existing contacts
* - Batch visibility settings
* - Auto-import for single new contacts
* - Error handling and validation
*
* State Management:
* - Tracks existing contacts
* - Maintains selection state for bulk imports
* - Records differences for duplicate contacts
* - Manages visibility settings
*
* Security Considerations:
* - JWT validation for imported contacts
* - Visibility control per contact
* - Error handling for malformed data
*
* @example
* // Component usage in router
* {
* path: "/contact-import/:jwt?",
* name: "contact-import",
* component: ContactImportView
* }
*
* @see {@link Contact} for contact data structure
* @see {@link setVisibilityUtil} for visibility management
*/
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
@@ -145,22 +216,75 @@ import {
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
/**
* Contact Import View Component
* @author Matthew Raymer
*
* This component handles the secure import of contacts into TimeSafari via JWT tokens.
* It supports both single and multiple contact imports with validation and duplicate detection.
*
* Import Workflows:
* 1. JWT in URL Path (/contact-import/[JWT])
* - Extracts JWT from path
* - Decodes and validates contact data
* - Handles both single and multiple contacts
*
* 2. JWT in Query Parameter (/contacts?contactJwt=[JWT])
* - Used for single contact redirects
* - Processes JWT from query parameter
* - Redirects to appropriate view
*
* JWT Payload Structure:
* ```json
* {
* "iat": 1740740453,
* "contacts": [{
* "did": "did:ethr:0x...",
* "name": "Optional Name",
* "nextPubKeyHashB64": "base64 string",
* "publicKeyBase64": "base64 string"
* }],
* "iss": "did:ethr:0x..."
* }
* ```
*
* Security Features:
* - JWT validation
* - Issuer verification
* - Duplicate detection
* - Contact data validation
*
* @component
*/
@Component({
components: { EntityIcon, OfferDialog, QuickNav },
})
export default class ContactImportView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void;
/** Current route instance */
$route!: RouteLocationNormalizedLoaded;
/** Router instance for navigation */
$router!: Router;
// Constants
AppString = AppString;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
libsUtil = libsUtil;
R = R;
// Component state
/** Active user's DID for authentication and visibility settings */
activeDid = "";
/** API server URL for backend communication */
apiServer = "";
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
contactsImporting: Array<Contact> = []; // contacts from the import
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
/** Map of existing contacts keyed by DID for duplicate detection */
contactsExisting: Record<string, Contact> = {};
/** Array of contacts being imported from JWT */
contactsImporting: Array<Contact> = [];
/** Selection state for each importing contact */
contactsSelected: Array<boolean> = [];
/** Differences between existing and importing contacts */
contactDifferences: Record<
string,
Record<
@@ -170,69 +294,117 @@ export default class ContactImportView extends Vue {
old: string | boolean | Array<ContactMethod> | undefined;
}
>
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
> = {};
/** Loading state for import operations */
checkingImports = false;
/** JWT input for manual contact import */
inputJwt: string = "";
/** Visibility setting for imported contacts */
makeVisible = true;
/** Count of duplicate contacts found */
sameCount = 0;
/**
* Component lifecycle hook that initializes the contact import process
*
* This method handles three distinct import scenarios:
* 1. Query Parameter Import:
* - Checks for contacts in URL query parameters
* - Parses JSON array of contacts if present
*
* 2. JWT URL Import:
* - Extracts JWT from URL path using regex pattern '/contact-import/(ey.+)$'
* - Decodes JWT without validation (supports future-dated QR codes)
* - Handles two JWT payload formats:
* a. Array format: payload.contacts or direct array
* b. Single contact format: redirects to contacts page with JWT
*
* 3. Auto-Import Logic:
* - Automatically imports if exactly one new contact is present
* - Only triggers if no existing contacts match
*
* @throws Will not throw but logs errors during JWT processing
* @emits router.push when redirecting for single contact import
*/
async created() {
await this.initializeSettings();
await this.processQueryParams();
await this.processJwtFromPath();
await this.handleAutoImport();
}
/**
* Initializes component settings from active account
*/
private async initializeSettings() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
}
// look for any imported contact array from the query parameter
const importedContacts = (this.$route as RouteLocationNormalizedLoaded)
.query["contacts"] as string;
/**
* Processes contacts from URL query parameters
*/
private async processQueryParams() {
const importedContacts = this.$route.query["contacts"] as string;
if (importedContacts) {
await this.setContactsSelected(JSON.parse(importedContacts));
}
}
/**
* Processes JWT from URL path and handles different JWT formats
*/
private async processJwtFromPath() {
// JWT tokens always start with 'ey' (base64url encoded header)
const JWT_PATTERN = /\/contact-import\/(ey.+)$/;
const jwt = window.location.pathname.match(JWT_PATTERN)?.[1];
// look for a JWT after /contact-import/ in the window.location.pathname
const jwt = window.location.pathname.match(
/\/contact-import\/(ey.+)$/,
)?.[1];
if (jwt) {
// would prefer to validate but we've got an error with JWTs on QR codes generated in the future
// eslint-disable-next-line prettier/prettier
// const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt);
// decode the JWT
const parsedJwt = decodeEndorserJwt(jwt);
const contacts: Array<Contact> =
parsedJwt.payload.contacts || // someday this will be the only payload sent to this page
parsedJwt.payload.contacts ||
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
if (!contacts && parsedJwt.payload.own) {
// handle this single-contact JWT in the contacts page, better suited to single additions
(this.$router as Router).push({
this.$router.push({
name: "contacts",
query: { contactJwt: jwt },
});
return;
}
if (contacts) {
await this.setContactsSelected(contacts);
} else {
// no contacts found so default message should be OK
}
}
}
/**
* Handles automatic import for single new contacts
*/
private async handleAutoImport() {
if (
this.contactsImporting.length === 1 &&
R.isEmpty(this.contactsExisting)
) {
// if there is only one contact and it's new, then we will automatically import it
this.contactsSelected[0] = true;
this.importContacts(); // ... which routes to the contacts list
await this.importContacts();
}
}
/**
* Processes contacts for import and checks for duplicates
* @param contacts Array of contacts to process
*/
async setContactsSelected(contacts: Array<Contact>) {
this.contactsImporting = contacts;
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
await db.open();
const baseContacts = await db.contacts.toArray();
// set the existing contacts, keyed by DID, if they exist in contactsImporting
// Check for existing contacts and differences
for (let i = 0; i < this.contactsImporting.length; i++) {
const contactIn = this.contactsImporting[i];
const existingContact = baseContacts.find(
@@ -241,6 +413,7 @@ export default class ContactImportView extends Vue {
if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact;
// Compare contact fields for differences
const differences: Record<
string,
{
@@ -249,8 +422,12 @@ export default class ContactImportView extends Vue {
}
> = {};
Object.keys(contactIn).forEach((key) => {
// eslint-disable-next-line prettier/prettier
if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) {
if (
!R.equals(
contactIn[key as keyof Contact],
existingContact[key as keyof Contact],
)
) {
differences[key] = {
old: existingContact[key as keyof Contact],
new: contactIn[key as keyof Contact],
@@ -262,13 +439,16 @@ export default class ContactImportView extends Vue {
this.sameCount++;
}
// don't automatically import previous data
// Don't auto-select duplicates
this.contactsSelected[i] = false;
}
}
}
// check the contact-import JWT
/**
* Validates contact import JWT format
* @param jwtInput JWT string to validate
*/
async checkContactJwt(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||
@@ -288,14 +468,15 @@ export default class ContactImportView extends Vue {
}
}
// process the invite JWT and/or text message containing the URL with the JWT
/**
* Processes contact import JWT and updates contacts
* @param jwtInput JWT string containing contact data
*/
async processContactJwt(jwtInput: string) {
this.checkingImports = true;
try {
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
// JWT format: { header, payload, signature, data }
const payload = decodeEndorserJwt(jwt).payload;
if (Array.isArray(payload.contacts)) {
@@ -319,10 +500,16 @@ export default class ContactImportView extends Vue {
this.checkingImports = false;
}
/**
* Imports selected contacts and sets visibility if requested
* Updates existing contacts or adds new ones
*/
async importContacts() {
this.checkingImports = true;
let importedCount = 0,
updatedCount = 0;
// Process selected contacts
for (let i = 0; i < this.contactsImporting.length; i++) {
if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i];
@@ -338,6 +525,8 @@ export default class ContactImportView extends Vue {
}
}
}
// Set visibility if requested
if (this.makeVisible) {
const failedVisibileToContacts = [];
for (let i = 0; i < this.contactsImporting.length; i++) {
@@ -375,6 +564,7 @@ export default class ContactImportView extends Vue {
this.checkingImports = false;
// Show success notification
this.$notify(
{
group: "alert",
@@ -386,7 +576,7 @@ export default class ContactImportView extends Vue {
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
this.$router.push({ name: "contacts" });
}
}
</script>

View File

@@ -10,7 +10,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw" />
<font-awesome icon="chevron-left" class="fa-fw" />
</h1>
</div>
@@ -26,10 +26,8 @@
You aren't sharing your name, so quickly
<br />
<span
@click="
() => $refs.userNameDialog.open((name) => (this.givenName = name))
"
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 px-1.5 py-1 rounded-md"
@click="() => $refs.userNameDialog.open((name) => (givenName = name))"
>
click here to set it for them.
</span>
@@ -38,18 +36,18 @@
<UserNameDialog ref="userNameDialog" />
<div
@click="onCopyUrlToClipboard()"
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="text-center"
@click="onCopyUrlToClipboard()"
>
<!--
Play with display options: https://qr-code-styling.com/
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
-->
<QRCodeVue3
:value="this.qrValue"
:cornersSquareOptions="{ type: 'extra-rounded' }"
:dotsOptions="{ type: 'square' }"
:value="qrValue"
:corners-square-options="{ type: 'extra-rounded' }"
:dots-options="{ type: 'square' }"
class="flex justify-center"
/>
<span>
@@ -58,14 +56,14 @@
</div>
<div v-else-if="activeDid" class="text-center">
<!-- Not an ETHR DID so force them to paste it. (Passkey Peer DIDs are too big.) -->
<span @click="onCopyDidToClipboard()" class="text-blue-500">
<span class="text-blue-500" @click="onCopyDidToClipboard()">
Click here to copy your DID to your clipboard.
</span>
<span>
Then give it to them so they can paste it in their list of People.
</span>
</div>
<div class="text-center" v-else>
<div v-else class="text-center">
You have no identitifiers yet, so
<router-link
:to="{ name: 'start' }"
@@ -110,7 +108,8 @@ import {
} from "../libs/endorserServer";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { retrieveAccountMetadata } from "../libs/util";
import { Router } from "vue-router";
import { logger } from "../utils/logger";
@Component({
components: {
QrcodeStream,
@@ -121,6 +120,7 @@ import { retrieveAccountMetadata } from "../libs/util";
})
export default class ContactQRScanShow extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
activeDid = "";
apiServer = "";
@@ -210,7 +210,7 @@ export default class ContactQRScanShow extends Vue {
return;
}
} catch (e) {
console.error("Error parsing QR info:", e);
logger.error("Error parsing QR info:", e);
this.danger("Could not parse the QR info.", "Read Error");
return;
}
@@ -274,7 +274,7 @@ export default class ContactQRScanShow extends Vue {
}
}
} catch (e) {
console.error("Error saving contact info:", e);
logger.error("Error saving contact info:", e);
this.$notify(
{
group: "alert",
@@ -310,7 +310,7 @@ export default class ContactQRScanShow extends Vue {
if (result.error) {
this.danger(result.error as string, "Error Setting Visibility");
} else if (!result.success) {
console.error("Got strange result from setting visibility:", result);
logger.error("Got strange result from setting visibility:", result);
}
}
@@ -360,7 +360,7 @@ export default class ContactQRScanShow extends Vue {
);
}
} catch (error) {
console.error("Error when registering:", error);
logger.error("Error when registering:", error);
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
@@ -389,7 +389,7 @@ export default class ContactQRScanShow extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) {
console.error("Scan was invalid:", error);
logger.error("Scan was invalid:", error);
this.$notify(
{
group: "alert",

View File

@@ -7,8 +7,8 @@
<router-link
:to="{ name: 'account' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa
></router-link>
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Scan Contact
</h1>

View File

@@ -23,26 +23,26 @@
<!-- New Contact -->
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<span class="flex" v-if="isRegistered">
<span v-if="isRegistered" class="flex">
<router-link
:to="{ name: 'invite-one' }"
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"
>
<fa icon="envelope-open-text" class="fa-fw text-2xl" />
<font-awesome icon="envelope-open-text" class="fa-fw text-2xl" />
</router-link>
<button
@click="showOnboardMeetingDialog()"
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"
@click="showOnboardMeetingDialog()"
>
<fa icon="chair" class="fa-fw text-2xl" />
<font-awesome icon="chair" class="fa-fw text-2xl" />
</button>
</span>
<span v-else class="flex">
<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"
>
<fa
<font-awesome
icon="envelope-open-text"
class="fa-fw text-2xl"
@click="
@@ -56,7 +56,7 @@
<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"
>
<fa
<font-awesome
icon="chair"
class="fa-fw text-2xl"
@click="
@@ -73,38 +73,39 @@
:to="{ name: 'contact-qr' }"
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"
>
<fa icon="qrcode" class="fa-fw text-2xl" />
<font-awesome icon="qrcode" class="fa-fw text-2xl" />
</router-link>
<textarea
v-model="contactInput"
type="text"
placeholder="New URL or DID, Name, Public Key, Next Public Key Hash"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2 h-10"
v-model="contactInput"
/>
<button
class="px-4 rounded-r bg-green-200 border border-l-0 border-green-400"
@click="onClickNewContact()"
>
<fa icon="plus" class="fa-fw" />
<font-awesome icon="plus" class="fa-fw" />
</button>
</div>
<div class="flex justify-between" v-if="contacts.length > 0">
<div v-if="contacts.length > 0" class="flex justify-between">
<div class="w-full text-left">
<div v-if="!showGiveNumbers">
<input
type="checkbox"
:checked="contactsSelected.length === contacts.length"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
/>
<button
v-if="!showGiveNumbers"
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
@@ -112,14 +113,16 @@
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
data-testId="copySelectedContactsButtonTop"
@click="copySelectedContacts()"
>
Copy Selections
</button>
<button @click="showCopySelectionsInfo()">
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
<font-awesome
icon="circle-info"
class="text-xl text-blue-500 ml-4"
/>
</button>
</div>
</div>
@@ -136,20 +139,20 @@
</button>
</div>
</div>
<div class="flex justify-between mt-1" v-if="showGiveNumbers">
<div v-if="showGiveNumbers" class="flex justify-between mt-1">
<div class="w-full text-right">
In the following, only the most recent hours are included. To see more,
click
<span
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 rounded-md"
>
<fa icon="file-lines" class="fa-fw" />
<font-awesome icon="file-lines" class="fa-fw" />
</span>
<br />
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md mt-1"
v-bind:class="showGiveAmountsClassNames()"
:class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()"
>
{{
@@ -159,36 +162,38 @@
? "Confirmed Amounts"
: "Unconfirmed Amounts"
}}
<fa icon="left-right" class="fa-fw" />
<font-awesome icon="left-right" class="fa-fw" />
</button>
</div>
</div>
<!-- Results List -->
<ul
id="listContacts"
v-if="contacts.length > 0"
id="listContacts"
class="border-t border-slate-300 mt-1"
>
<li
class="border-b border-slate-300 pt-1 pb-1"
v-for="contact in filteredContacts()"
:key="contact.did"
class="border-b border-slate-300 pt-1 pb-1"
data-testId="contactListItem"
>
<div class="grow overflow-hidden">
<div class="flex items-center">
<EntityIcon
:contact="contact"
:iconSize="24"
:icon-size="24"
class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer"
@click="showLargeIdenticon = contact"
/>
<input
type="checkbox"
v-if="!showGiveNumbers"
type="checkbox"
:checked="contactsSelected.includes(contact.did)"
class="ml-2 h-6 w-6 flex-shrink-0"
data-testId="contactCheckOne"
@click="
contactsSelected.includes(contact.did)
? contactsSelected.splice(
@@ -197,8 +202,6 @@
)
: contactsSelected.push(contact.did)
"
class="ml-2 h-6 w-6 flex-shrink-0"
data-testId="contactCheckOne"
/>
<h2
@@ -215,7 +218,10 @@
}"
title="See more about this person"
>
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
<font-awesome
icon="circle-info"
class="text-xl text-blue-500 ml-4"
/>
</router-link>
<span class="ml-4 text-sm overflow-hidden">{{
@@ -234,17 +240,17 @@
>
<button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-l-md"
@click="confirmShowGiftedDialog(contact.did, activeDid)"
:title="givenToMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(contact.did, activeDid)"
>
From:
<br />
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
showGiveTotals
? ((givenToMeConfirmed[contact.did] || 0)
+ (givenToMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
: showGiveConfirmed
? (givenToMeConfirmed[contact.did] || 0)
: (givenToMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
@@ -253,17 +259,17 @@
<button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white -ml-1.5 px-2 py-1.5 rounded-r-md border-l"
@click="confirmShowGiftedDialog(activeDid, contact.did)"
:title="givenByMeDescriptions[contact.did] || ''"
@click="confirmShowGiftedDialog(activeDid, contact.did)"
>
To:
<br />
{{
/* eslint-disable prettier/prettier */
this.showGiveTotals
showGiveTotals
? ((givenByMeConfirmed[contact.did] || 0)
+ (givenByMeUnconfirmed[contact.did] || 0))
: this.showGiveConfirmed
: showGiveConfirmed
? (givenByMeConfirmed[contact.did] || 0)
: (givenByMeUnconfirmed[contact.did] || 0)
/* eslint-enable prettier/prettier */
@@ -272,8 +278,8 @@
<button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400"
@click="openOfferDialog(contact.did, contact.name)"
data-testId="offerButton"
@click="openOfferDialog(contact.did, contact.name)"
>
Offer
</button>
@@ -286,7 +292,7 @@
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-slate-400"
title="See more given activity"
>
<fa icon="file-lines" class="fa-fw" />
<font-awesome icon="file-lines" class="fa-fw" />
</router-link>
</div>
</div>
@@ -295,20 +301,21 @@
</ul>
<p v-else>There are no contacts.</p>
<div class="mt-2 w-full text-left" v-if="contacts.length > 0">
<div v-if="contacts.length > 0" class="mt-2 w-full text-left">
<input
type="checkbox"
v-if="!showGiveNumbers"
type="checkbox"
:checked="contactsSelected.length === contacts.length"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllBottom"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllBottom"
/>
<button
v-if="!showGiveNumbers"
href=""
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
@@ -317,7 +324,6 @@
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
>
Copy Selections
</button>
@@ -333,7 +339,7 @@
>
<EntityIcon
:contact="showLargeIdenticon"
:iconSize="512"
:icon-size="512"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="showLargeIdenticon = undefined"
/>
@@ -386,7 +392,7 @@ import {
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { generateSaveAndActivateIdentity } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: {
GiftedDialog,
@@ -399,6 +405,8 @@ import { generateSaveAndActivateIdentity } from "../libs/util";
})
export default class ContactsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
activeDid = "";
apiServer = "";
@@ -465,8 +473,7 @@ export default class ContactsView extends Vue {
//
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["contactJwt"] as string;
const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) {
// really should fully verify contents
const { payload } = decodeEndorserJwt(importedContactJwt);
@@ -481,14 +488,13 @@ export default class ContactsView extends Vue {
} as Contact;
await this.addContact(newContact);
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
(this.$router as Router).push({ path: "/contacts" });
this.$router.push({ path: "/contacts" });
}
}
private async processInviteJwt() {
// handle an invite JWT sent via URL
const importedInviteJwt = (this.$route as RouteLocationNormalizedLoaded)
.query["inviteJwt"] as string;
const importedInviteJwt = this.$route.query["inviteJwt"] as string;
if (importedInviteJwt === "") {
// this happens when a platform (eg iOS) doesn't include anything after the "=" in a shared link.
this.$notify(
@@ -590,7 +596,7 @@ export default class ContactsView extends Vue {
);
}
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
(this.$router as Router).push({ path: "/contacts" });
this.$router.push({ path: "/contacts" });
}
}
@@ -630,7 +636,7 @@ export default class ContactsView extends Vue {
title: "They're Added To Your List",
text: "Would you like to go to the main page now?",
onYes: async () => {
(this.$router as Router).push({ name: "home" });
this.$router.push({ name: "home" });
},
},
-1,
@@ -677,7 +683,7 @@ export default class ContactsView extends Vue {
}
}
} else {
console.error(
logger.error(
"Got bad response status & data of",
resp.status,
resp.data,
@@ -767,9 +773,7 @@ export default class ContactsView extends Vue {
if (contactInput.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
const jwt = getContactJwtFromJwtUrl(contactInput);
(this.$router as Router).push({
path: "/contact-import/" + jwt,
});
this.$router.push({ path: "/contact-import/" + jwt });
return;
}
@@ -877,7 +881,7 @@ export default class ContactsView extends Vue {
);
try {
const contacts = JSON.parse(jsonContactInput);
(this.$router as Router).push({
this.$router.push({
name: "contact-import",
query: { contacts: JSON.stringify(contacts) },
});
@@ -1157,7 +1161,7 @@ export default class ContactsView extends Vue {
}
return true;
} else {
console.error(
logger.error(
"Got strange result from setting visibility. It can happen when setting visibility on oneself.",
result,
);
@@ -1203,7 +1207,7 @@ export default class ContactsView extends Vue {
this.showGiftedDialog(giverDid, recipientDid);
},
onYes: async () => {
(this.$router as Router).push({
this.$router.push({
name: "contact-amounts",
query: { contactDid: giverDid },
});
@@ -1403,10 +1407,10 @@ export default class ContactsView extends Vue {
if (hostResponse.data.data) {
// They're the host, take them to setup
(this.$router as Router).push({ name: "onboard-meeting-setup" });
this.$router.push({ name: "onboard-meeting-setup" });
} else {
// They're not the host, take them to list
(this.$router as Router).push({ name: "onboard-meeting-list" });
this.$router.push({ name: "onboard-meeting-list" });
}
} else {
// They're not in a meeting, show the dialog
@@ -1417,11 +1421,11 @@ export default class ContactsView extends Vue {
title: "Onboarding Meeting",
text: "Would you like to start a new meeting?",
onYes: async () => {
(this.$router as Router).push({ name: "onboard-meeting-setup" });
this.$router.push({ name: "onboard-meeting-setup" });
},
yesText: "Start New Meeting",
onNo: async () => {
(this.$router as Router).push({ name: "onboard-meeting-list" });
this.$router.push({ name: "onboard-meeting-list" });
},
noText: "Join Existing Meeting",
},

View File

@@ -9,10 +9,10 @@
<h1 id="ViewHeading" class="text-lg text-center font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Identifier Details
</h1>
@@ -29,16 +29,20 @@
<router-link
:to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</router-link>
</h2>
<button
@click="showDidDetails = !showDidDetails"
class="ml-2 mr-2 mt-4"
@click="showDidDetails = !showDidDetails"
>
Details
<fa v-if="showDidDetails" icon="chevron-down" class="text-blue-400" />
<fa v-else icon="chevron-right" class="text-blue-400" />
<font-awesome
v-if="showDidDetails"
icon="chevron-down"
class="text-blue-400"
/>
<font-awesome v-else icon="chevron-right" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
@@ -54,7 +58,7 @@
>
<EntityIcon
:icon-size="96"
:profileImageUrl="contactFromDid?.profileImageUrl"
:profile-image-url="contactFromDid?.profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded"
@click="showLargeIdenticonUrl = contactFromDid?.profileImageUrl"
/>
@@ -69,61 +73,65 @@
contactFromDid?.seesMe && contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contactFromDid, false)"
title="They can see you"
@click="confirmSetVisibility(contactFromDid, false)"
>
<fa icon="eye" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contactFromDid, true)"
title="They cannot see you"
@click="confirmSetVisibility(contactFromDid, true)"
>
<fa icon="eye-slash" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
</button>
<button
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="checkVisibility(contactFromDid)"
title="Check Visibility"
v-if="contactFromDid?.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Check Visibility"
@click="checkVisibility(contactFromDid)"
>
<fa icon="rotate" class="fa-fw" />
<font-awesome icon="rotate" class="fa-fw" />
</button>
</div>
<button
@click="confirmRegister(contactFromDid)"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
v-if="contactFromDid?.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Registration"
@click="confirmRegister(contactFromDid)"
>
<fa
<font-awesome
v-if="contactFromDid?.registered"
icon="person-circle-check"
class="fa-fw"
/>
<fa v-else icon="person-circle-question" class="fa-fw" />
<font-awesome
v-else
icon="person-circle-question"
class="fa-fw"
/>
</button>
</div>
<button
@click="confirmDeleteContact(contactFromDid)"
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Delete"
@click="confirmDeleteContact(contactFromDid)"
>
<fa icon="trash-can" class="fa-fw" />
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<div v-if="!contactFromDid?.profileImageUrl">
<div>Auto-Generated Icon</div>
<div class="flex justify-center">
<EntityIcon
:entityId="viewingDid"
:iconSize="64"
:entity-id="viewingDid"
:icon-size="64"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
@click="showLargeIdenticonId = viewingDid"
/>
@@ -138,9 +146,9 @@
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
>
<EntityIcon
:entityId="showLargeIdenticonId"
:iconSize="512"
:profileImageUrl="showLargeIdenticonUrl"
:entity-id="showLargeIdenticonId"
:icon-size="512"
:profile-image-url="showLargeIdenticonUrl"
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
@click="
showLargeIdenticonId = undefined;
@@ -161,10 +169,10 @@
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
<font-awesome icon="spinner" class="fa-spin-pulse"></font-awesome>
</div>
<!-- Results List -->
<div v-if="claims.length > 0" class="mt-4">
@@ -175,9 +183,9 @@
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<li
class="border-b border-slate-300"
v-for="claim in claims"
:key="claim.handleId"
class="border-b border-slate-300"
>
<div class="grid grid-cols-12 gap-4">
<span class="col-span-2">
@@ -193,8 +201,11 @@
{{ claimDescription(claim) }}
</span>
<span class="col-span-1">
<a @click="onClickLoadClaim(claim.id)" class="cursor-pointer">
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
<a class="cursor-pointer" @click="onClickLoadClaim(claim.id)">
<font-awesome
icon="file-lines"
class="pl-2 pt-1 text-blue-500"
/>
</a>
</span>
</div>
@@ -216,7 +227,7 @@
import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue";
@@ -226,20 +237,34 @@ import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { BoundingBox } from "../db/tables/settings";
import {
capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact,
displayAmount,
getHeaders,
GenericCredWrapper,
GenericVerifiableCredential,
GiveVerifiableCredential,
OfferVerifiableCredential,
} from "../interfaces";
import {
capitalizeAndInsertSpacesBeforeCaps,
didInfoForContact,
displayAmount,
getHeaders,
register,
setVisibilityUtil,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import EntityIcon from "../components/EntityIcon.vue";
import { logger } from "../utils/logger";
/**
* DIDView Component
*
* Displays detailed information about a DID (Decentralized Identifier) entity, including:
* - Basic identity information (name, profile image)
* - Contact management controls (visibility, registration status)
* - Associated claims and their details
*
* The view supports both viewing one's own DID and other contacts' DIDs.
* It provides infinite scrolling for claims and interactive controls for contact management.
*/
@Component({
components: {
EntityIcon,
@@ -250,6 +275,8 @@ import EntityIcon from "../components/EntityIcon.vue";
})
export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
libsUtil = libsUtil;
yaml = yaml;
@@ -272,51 +299,111 @@ export default class DIDView extends Vue {
didInfoForContact = didInfoForContact;
displayAmount = displayAmount;
/**
* Initializes the view with DID information
*
* Workflow:
* 1. Retrieves active account settings (DID and API server)
* 2. Determines which DID to display from URL params or defaults to active DID
* 3. Loads contact information if available
* 4. Loads associated claims
* 5. Determines if viewing own DID
*/
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParam = window.location.pathname.substring("/did/".length);
let showDid = pathParam;
if (!showDid) {
showDid = this.activeDid;
if (showDid) {
this.$notify(
{
group: "alert",
type: "toast",
title: "Your Info",
text: "No user was specified so showing your info.",
},
3000,
);
}
}
if (showDid) {
this.viewingDid = decodeURIComponent(showDid);
this.contactFromDid = await db.contacts.get(this.viewingDid);
if (this.contactFromDid) {
this.contactYaml = yaml.dump(this.contactFromDid);
}
await this.initializeSettings();
await this.determineDIDToDisplay();
if (this.viewingDid) {
await this.loadContactInformation();
await this.loadClaimsAbout();
const allAccountDids = await libsUtil.retrieveAccountDids();
this.isMyDid = allAccountDids.includes(this.viewingDid);
await this.checkIfOwnDID();
}
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
* Initializes component settings from active account
*/
private async initializeSettings() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
}
/**
* Determines which DID to display based on URL parameters
* Falls back to active DID if no parameter provided
*/
private async determineDIDToDisplay() {
const pathParam = window.location.pathname.substring("/did/".length);
let showDid = pathParam;
if (!showDid) {
showDid = this.activeDid;
if (showDid) {
this.notifyDefaultToActiveDID();
}
}
if (showDid) {
this.viewingDid = decodeURIComponent(showDid);
}
}
/**
* Notifies user that we're showing their DID info by default
*/
private notifyDefaultToActiveDID() {
this.$notify(
{
group: "alert",
type: "toast",
title: "Your Info",
text: "No user was specified so showing your info.",
},
3000,
);
}
/**
* Loads contact information for the viewing DID
* Updates contact YAML representation if contact exists
*/
private async loadContactInformation() {
if (!this.viewingDid) return;
this.contactFromDid = await db.contacts.get(this.viewingDid);
if (this.contactFromDid) {
this.contactYaml = yaml.dump(this.contactFromDid);
}
}
/**
* Checks if the viewing DID belongs to the current user
*/
private async checkIfOwnDID() {
if (!this.viewingDid) return;
const allAccountDids = await libsUtil.retrieveAccountDids();
this.isMyDid = allAccountDids.includes(this.viewingDid);
}
/**
* Loads additional claims when user scrolls to bottom
* Used by infinite scroll component to implement pagination
*
* @param payload - Boolean indicating if more data should be loaded
*/
async loadMoreData(payload: boolean) {
if (this.claims.length > 0 && !this.hitEnd && payload) {
this.loadClaimsAbout();
}
}
// prompt with confirmation if they want to delete a contact
/**
* Prompts user to confirm contact deletion
* Shows additional warning if contact has visibility permissions
*
* @param contact - Contact object to be deleted
*/
confirmDeleteContact(contact: Contact) {
let message =
"Are you sure you want to remove " +
@@ -340,6 +427,11 @@ export default class DIDView extends Vue {
);
}
/**
* Deletes contact from local database and navigates back to contacts list
*
* @param contact - Contact object to be deleted
*/
async deleteContact(contact: Contact) {
await db.open();
await db.contacts.delete(contact.did);
@@ -352,10 +444,15 @@ export default class DIDView extends Vue {
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
this.$router.push({ name: "contacts" });
}
// confirm to register a new contact
/**
* Prompts user to confirm registering a contact
* Shows additional warning if contact is already registered
*
* @param contact - Contact to be registered
*/
async confirmRegister(contact: Contact) {
this.$notify(
{
@@ -377,7 +474,12 @@ export default class DIDView extends Vue {
);
}
// note that this is also in ContactView.vue
/**
* Registers a contact with the endorser server
* Updates local database with registration status
*
* @param contact - Contact to register
*/
async register(contact: Contact) {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
@@ -416,7 +518,7 @@ export default class DIDView extends Vue {
);
}
} catch (error) {
console.error("Error when registering:", error);
logger.error("Error when registering:", error);
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
@@ -443,9 +545,14 @@ export default class DIDView extends Vue {
}
}
/**
* Loads claims that involve the viewed DID
* Implements pagination using beforeId parameter
* Updates loading state and hit-end status
*/
public async loadClaimsAbout() {
if (!this.viewingDid) {
console.error("This should never be called without a DID.");
logger.error("This should never be called without a DID.");
return;
}
@@ -467,7 +574,7 @@ export default class DIDView extends Vue {
if (response.status !== 200) {
const details = await response.text();
console.error("Problem with full search:", details);
logger.error("Problem with full search:", details);
this.$notify(
{
group: "alert",
@@ -486,7 +593,7 @@ export default class DIDView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with feed load:", e);
logger.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
@@ -501,13 +608,25 @@ export default class DIDView extends Vue {
}
}
/**
* Navigates to detailed claim view
*
* @param jwtId - JWT ID of the claim to view
*/
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
(this.$router as Router).push(route);
this.$router.push(route);
}
/**
* Extracts and formats claim amount information
* Handles different claim types (GiveAction, Offer)
*
* @param claim - Claim object to process
* @returns Formatted amount string or empty string if no amount
*/
public claimAmount(claim: GenericVerifiableCredential) {
if (claim.claimType === "GiveAction") {
const giveClaim = claim.claim as GiveVerifiableCredential;
@@ -536,11 +655,23 @@ export default class DIDView extends Vue {
return "";
}
/**
* Extracts claim description
* Falls back to name if no description available
*
* @param claim - Claim to get description from
* @returns Description string or empty string
*/
claimDescription(claim: GenericVerifiableCredential) {
return claim.claim.name || claim.claim.description || "";
}
// note that this is also in ContactView.vue
/**
* Prompts user to confirm visibility change for a contact
*
* @param contact - Contact to modify visibility for
* @param visibility - New visibility state to set
*/
async confirmSetVisibility(contact: Contact, visibility: boolean) {
const visibilityPrompt = visibility
? "Are you sure you want to make your activity visible to them?"
@@ -562,7 +693,14 @@ export default class DIDView extends Vue {
);
}
// note that this is also in ContactView.vue
/**
* Updates contact visibility on server and local database
*
* @param contact - Contact to update visibility for
* @param visibility - New visibility state
* @param showSuccessAlert - Whether to show success notification
* @returns Boolean indicating success
*/
async setVisibility(
contact: Contact,
visibility: boolean,
@@ -596,7 +734,7 @@ export default class DIDView extends Vue {
}
return true;
} else {
console.error("Got strange result from setting visibility:", result);
logger.error("Got strange result from setting visibility:", result);
const message =
(result.error as string) || "Could not set visibility on the server.";
this.$notify(
@@ -612,7 +750,12 @@ export default class DIDView extends Vue {
}
}
// note that this is also in ContactView.vue
/**
* Checks current visibility status of contact on server
* Updates local database with current status
*
* @param contact - Contact to check visibility for
*/
async checkVisibility(contact: Contact) {
const url =
this.apiServer +
@@ -654,7 +797,7 @@ export default class DIDView extends Vue {
3000,
);
} else {
console.error("Got bad server response checking visibility:", resp);
logger.error("Got bad server response checking visibility:", resp);
const message = resp.data.error?.message || "Got bad server response.";
this.$notify(
{
@@ -667,7 +810,7 @@ export default class DIDView extends Vue {
);
}
} catch (err) {
console.error("Caught error from request to check visibility:", err);
logger.error("Caught error from request to check visibility:", err);
this.$notify(
{
group: "alert",

View File

@@ -18,17 +18,17 @@
:style="{ visibility: isSearchVisible ? 'visible' : 'hidden' }"
>
<input
type="text"
v-model="searchTerms"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
v-on:keyup.enter="searchSelected()"
@keyup.enter="searchSelected()"
/>
<button
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
@click="searchSelected()"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
<font-awesome icon="magnifying-glass" class="fa-fw"></font-awesome>
</button>
</div>
@@ -39,6 +39,7 @@
<li>
<a
href="#"
:class="computedProjectsTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
@@ -46,7 +47,6 @@
isPeopleActive = false;
searchSelected();
"
v-bind:class="computedProjectsTabStyleClassNames()"
>
Projects
</a>
@@ -54,6 +54,7 @@
<li>
<a
href="#"
:class="computedPeopleTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
@@ -61,7 +62,6 @@
isPeopleActive = true;
searchSelected();
"
v-bind:class="computedPeopleTabStyleClassNames()"
>
People
</a>
@@ -75,6 +75,7 @@
<li>
<a
href="#"
:class="computedLocalTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
@@ -85,7 +86,6 @@
tempSearchBox = null;
searchLocal();
"
v-bind:class="computedLocalTabStyleClassNames()"
>
Nearby
<!-- restore when the links don't jump around for different numbers
@@ -101,6 +101,7 @@
<li>
<a
href="#"
:class="computedMappedTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
@@ -111,7 +112,6 @@
searchTerms = '';
tempSearchBox = null;
"
v-bind:class="computedMappedTabStyleClassNames()"
>
<!-- search is triggered when map component gets to "ready" state -->
Mapped
@@ -120,6 +120,7 @@
<li>
<a
href="#"
:class="computedRemoteTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
@@ -130,7 +131,6 @@
tempSearchBox = null;
searchAll();
"
v-bind:class="computedRemoteTabStyleClassNames()"
>
Anywhere
<!-- restore when the links don't jump around for different numbers
@@ -152,7 +152,7 @@
class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="$router.push({ name: 'search-area' })"
>
<fa icon="location-dot" class="fa-fw" />
<font-awesome icon="location-dot" class="fa-fw" />
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
</button>
</div>
@@ -179,10 +179,10 @@
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
<font-awesome icon="spinner" class="fa-spin-pulse"></font-awesome>
</div>
<div
v-else-if="projects.length === 0 && userProfiles.length === 0"
@@ -191,7 +191,10 @@
<p class="text-lg text-slate-500">
<span v-if="isLocalActive">
<span v-if="searchBox"> None found in the selected area. </span>
<!-- Otherwise there's no search area selected so we'll just leave the search box for them to click. -->
<!--
Otherwise there's no search area selected so we'll just leave the search box for them
to click.
-->
</span>
<span v-else-if="isAnywhereActive"
>No projects were found with that search.</span
@@ -205,19 +208,19 @@
<!-- Projects List -->
<template v-if="isProjectsActive">
<li
class="border-b border-slate-300"
v-for="project in projects"
:key="project.handleId"
class="border-b border-slate-300"
>
<a
@click="onClickLoadItem(project.handleId)"
class="block py-4 flex gap-4 cursor-pointer"
@click="onClickLoadItem(project.handleId)"
>
<div>
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
:imageUrl="project.image"
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="block border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
@@ -225,7 +228,10 @@
<div class="grow">
<h2 class="text-base font-semibold">{{ project.name }}</h2>
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
<font-awesome
icon="user"
class="fa-fw text-slate-400"
></font-awesome>
{{
didInfo(
project.issuerDid,
@@ -243,17 +249,20 @@
<!-- Profiles List -->
<template v-else>
<li
class="border-b border-slate-300"
v-for="profile in userProfiles"
:key="profile.issuerDid"
class="border-b border-slate-300"
>
<a
@click="onClickLoadItem(profile?.rowId || '')"
class="block py-4 flex gap-4 cursor-pointer"
@click="onClickLoadItem(profile?.rowId || '')"
>
<div class="grow">
<div class="text-sm">
<fa icon="user" class="fa-fw text-slate-400"></fa>
<font-awesome
icon="user"
class="fa-fw text-slate-400"
></font-awesome>
{{
didInfo(
profile.issuerDid,
@@ -273,7 +282,10 @@
v-if="isAnywhereActive && profile.locLat && profile.locLon"
class="mt-1 text-xs text-slate-500"
>
<fa icon="location-dot" class="fa-fw"></fa>
<font-awesome
icon="location-dot"
class="fa-fw"
></font-awesome>
{{
(profile.locLat > 0 ? "North" : "South") +
" in " +
@@ -313,14 +325,14 @@ import {
} from "../db/index";
import { Contact } from "../db/tables/contacts";
import { BoundingBox } from "../db/tables/settings";
import { PlanData } from "../interfaces";
import {
didInfo,
errorStringForLog,
getHeaders,
PlanData,
} from "../libs/endorserServer";
import { OnboardPage, retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
interface Tile {
indexLat: number;
indexLon: number;
@@ -493,9 +505,9 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with search all:", e);
logger.error("Error with search all:", e);
// this sometimes gives different information
console.error("Error with search all (error added): " + e);
logger.error("Error with search all (error added): " + e);
this.$notify(
{
group: "alert",
@@ -587,7 +599,7 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
console.error("Error with search local:", e);
logger.error("Error with search local:", e);
this.$notify(
{
group: "alert",

View File

@@ -13,7 +13,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -44,9 +44,9 @@
>
</h1>
<textarea
v-model="description"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What was received"
v-model="description"
/>
<div class="flex flex-row justify-center">
<span
@@ -59,18 +59,18 @@
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
<font-awesome icon="chevron-left" />
</div>
<input
v-model="amountInput"
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
<font-awesome icon="chevron-right" />
</div>
</div>
@@ -79,14 +79,14 @@
<a :href="imageUrl" target="_blank">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
<font-awesome
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
@click="confirmDeleteImage"
/>
</span>
<span v-else>
<fa
<font-awesome
icon="camera"
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 px-2 py-2 rounded-md"
@click="openImageDialog"
@@ -101,11 +101,11 @@
<div class="flex">
<input
v-if="giverDid && !providedByProject"
v-model="providedByGiver"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="providedByGiver"
/>
<fa
<font-awesome
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
@@ -117,7 +117,7 @@
: "No named individual gave."
}}
</label>
<fa
<font-awesome
v-if="!giverDid || providedByProject"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@@ -128,11 +128,11 @@
<div class="flex">
<input
v-if="providerProjectId && !providedByGiver"
v-model="providedByProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="providedByProject"
/>
<fa
<font-awesome
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
@@ -144,7 +144,7 @@
: "This was not provided by a project."
}}
</label>
<fa
<font-awesome
v-if="!providerProjectId || providedByGiver"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@@ -154,7 +154,7 @@
</div>
<div class="flex-shrink flex justify-center items-center">
<fa icon="arrow-right" class="fa-fw h-7" />
<font-awesome icon="arrow-right" class="fa-fw h-7" />
</div>
<!-- Third Column for Recipient -->
@@ -162,11 +162,11 @@
<div class="flex">
<input
v-if="recipientDid && !givenToProject"
v-model="givenToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToRecipient"
/>
<fa
<font-awesome
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
@@ -178,7 +178,7 @@
: "No individual benefitted."
}}
</label>
<fa
<font-awesome
v-if="!recipientDid || givenToProject"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@@ -189,11 +189,11 @@
<div class="flex">
<input
v-if="fulfillsProjectId && !givenToRecipient"
v-model="givenToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="givenToProject"
/>
<fa
<font-awesome
v-else
icon="square"
class="mr-2 bg-white text-white h-5 w-5 px-0.5 py-0.5 rounded-sm"
@@ -205,7 +205,7 @@
: "No project benefitted."
}}
</label>
<fa
<font-awesome
v-if="!fulfillsProjectId || givenToRecipient"
icon="info-circle"
class="-mt-1 bg-white text-slate-500 h-5 w-5 px-0.5 py-0.5 rounded-sm"
@@ -216,7 +216,7 @@
</div>
<div class="mt-8 flex">
<input type="checkbox" class="h-6 w-6 mr-2" v-model="isTrade" />
<input v-model="isTrade" type="checkbox" class="h-6 w-6 mr-2" />
<label class="text-sm mt-1">This was a trade (not a gift)</label>
</div>
@@ -236,7 +236,7 @@
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
<font-awesome
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
@@ -261,25 +261,25 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { GenericCredWrapper, GiveVerifiableCredential } from "../interfaces";
import {
createAndSubmitGive,
didInfo,
editAndSubmitGive,
GenericCredWrapper,
getHeaders,
getPlanFromCache,
GiveVerifiableCredential,
hydrateGive,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: {
@@ -290,6 +290,8 @@ import { retrieveAccountDids } from "../libs/util";
})
export default class GiftedDetails extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
activeDid = "";
apiServer = "";
@@ -322,9 +324,9 @@ export default class GiftedDetails extends Vue {
async mounted() {
try {
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
this.prevCredToEdit = (this.$route.query["prevCredToEdit"] as string)
? (JSON.parse(
(this.$route as Router).query["prevCredToEdit"],
this.$route.query["prevCredToEdit"] as string,
) as GenericCredWrapper<GiveVerifiableCredential>)
: undefined;
} catch (error) {
@@ -341,24 +343,23 @@ export default class GiftedDetails extends Vue {
const prevAmount = this.prevCredToEdit?.claim?.object?.amountOfThisGood;
this.amountInput =
(this.$route as Router).query["amountInput"] ||
(this.$route.query["amountInput"] as string) ||
(prevAmount ? String(prevAmount) : "") ||
this.amountInput;
this.description =
(this.$route as Router).query["description"] ||
(this.$route.query["description"] as string) ||
this.prevCredToEdit?.claim?.description ||
this.description;
this.destinationPathAfter = (this.$route as Router).query[
"destinationPathAfter"
];
this.giverDid = ((this.$route as Router).query["giverDid"] ||
this.prevCredToEdit?.claim?.agent?.identifier ||
this.destinationPathAfter =
(this.$route.query["destinationPathAfter"] as string) || "";
this.giverDid = ((this.$route.query["giverDid"] as string) ||
(this.prevCredToEdit?.claim?.agent as unknown as { identifier: string })
?.identifier ||
this.giverDid) as string;
this.giverName =
((this.$route as Router).query["giverName"] as string) || "";
this.giverName = (this.$route.query["giverName"] as string) || "";
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
(this.$route.query["hideBackButton"] as string) === "true";
this.message = (this.$route.query["message"] as string) || "";
// find any offer ID
const fulfills = this.prevCredToEdit?.claim?.fulfills;
@@ -368,7 +369,7 @@ export default class GiftedDetails extends Vue {
? [fulfills]
: [];
const offer = fulfillsArray.find((rec) => rec["@type"] === "Offer");
this.offerId = ((this.$route as Router).query["offerId"] ||
this.offerId = ((this.$route.query["offerId"] as string) ||
offer?.identifier ||
this.offerId) as string;
@@ -378,7 +379,7 @@ export default class GiftedDetails extends Vue {
);
// eslint-disable-next-line prettier/prettier
this.fulfillsProjectId =
((this.$route as Router).query["fulfillsProjectId"] ||
((this.$route.query["fulfillsProjectId"] as string) ||
fulfillsProject?.identifier ||
this.fulfillsProjectId) as string;
@@ -392,40 +393,38 @@ export default class GiftedDetails extends Vue {
const providerProject = providerArray.find(
(rec) => rec["@type"] === "PlanAction",
);
this.providerProjectId = ((this.$route as Router).query[
this.providerProjectId = ((this.$route.query[
"providerProjectId"
] ||
] as string) ||
providerProject?.identifier ||
this.providerProjectId) as string;
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
this.recipientDid = ((this.$route.query["recipientDid"] as string) ||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
this.recipientName =
((this.$route as Router).query["recipientName"] as string) || "";
this.unitCode = ((this.$route as Router).query["unitCode"] ||
this.recipientName = (this.$route.query["recipientName"] as string) || "";
this.unitCode = ((this.$route.query["unitCode"] as string) ||
this.prevCredToEdit?.claim?.object?.unitCode ||
this.unitCode) as string;
this.imageUrl =
((this.$route as Router).query["imageUrl"] as string) ||
this.imageUrl = ((this.$route.query["imageUrl"] as string) ||
this.prevCredToEdit?.claim?.image ||
localStorage.getItem("imageUrl") ||
this.imageUrl;
this.imageUrl) as string;
// this is an endpoint for sharing project info to highlight something given
// https://developer.mozilla.org/en-US/docs/Web/Manifest/share_target
if ((this.$route as Router).query["shareTitle"]) {
if (this.$route.query["shareTitle"] as string) {
this.description =
((this.$route as Router).query["shareTitle"] as string) +
((this.$route.query["shareTitle"] as string) || "") +
(this.description ? "\n" + this.description : "");
}
if ((this.$route as Router).query["shareText"]) {
if (this.$route.query["shareText"] as string) {
this.description =
(this.description ? this.description + "\n" : "") +
((this.$route as Router).query["shareText"] as string);
((this.$route.query["shareText"] as string) || "");
}
if ((this.$route as Router).query["shareUrl"]) {
this.imageUrl = (this.$route as Router).query["shareUrl"] as string;
if (this.$route.query["shareUrl"] as string) {
this.imageUrl = this.$route.query["shareUrl"] as string;
}
const settings = await retrieveSettingsForActiveAccount();
@@ -551,7 +550,7 @@ export default class GiftedDetails extends Vue {
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
console.log(
logger.log(
"Using shared image API server, so only users on that server can play with images.",
);
}
@@ -565,7 +564,7 @@ export default class GiftedDetails extends Vue {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Problem deleting image:", response);
logger.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
@@ -582,10 +581,10 @@ export default class GiftedDetails extends Vue {
localStorage.removeItem("imageUrl");
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
logger.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("Weird: the image was already deleted.", error);
logger.log("Weird: the image was already deleted.", error);
localStorage.removeItem("imageUrl");
this.imageUrl = "";
@@ -818,7 +817,7 @@ export default class GiftedDetails extends Vue {
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result);
console.error("Error with give creation result:", result);
logger.error("Error with give creation result:", result);
this.$notify(
{
group: "alert",
@@ -847,7 +846,7 @@ export default class GiftedDetails extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with give recordation caught:", error);
logger.error("Error with give recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||

View File

@@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -42,16 +42,21 @@
<h2 class="text-xl font-semibold mt-4">New Activity Notifications</h2>
<div>
<p>
The New Activity Notification will be sent to you when there is new, relevant activity for you.
The New Activity Notification will be sent to you when there is new, relevant activity
for you.
It will only trigger if something involves you or a project of interest; it will not
bug you for other, general activity.
</p>
<p>
This type is not as reliable as a Reminder Notification because mobile devices often suppress
such notifications to save battery. (If you want to quickly check for relevant activity daily,
use the Reminder Notification and open the app and look for a large green button that points out new
activity that is personal to you. We are working on other ways to notify you more
reliably -- <router-link class="text-blue-500" to="/help">go here to follow us or contact us</router-link>.)
This type is not as reliable as a Reminder Notification because mobile devices often
suppress such notifications to save battery. (If you want to quickly check for relevant
activity daily, use the Reminder Notification and open the app and look for a large green
button that points out new activity that is personal to you. We are working on other
ways to notify you more reliably.
<router-link class="text-blue-500" to="/help">
go here to follow us or contact us
<font-awesome icon="chevron-right" class="fa-fw"></font-awesome>
</router-link>.)
</p>
</div>
</div>

View File

@@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -30,8 +30,8 @@
<p>
If this works then you're all set.
<button
@click="sendTestWebPushMessage(true)"
class="block w-full text-center text-md 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-2 rounded-md mb-2"
@click="sendTestWebPushMessage(true)"
>
Send Yourself a Test Web Push Message (Through Push Server but
Skipping Client Filter)
@@ -140,7 +140,7 @@
class="text-blue-500"
target="_blank"
>
here <fa icon="arrow-up-right-from-square" class="fa-fw" />
here <font-awesome icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</div>
@@ -186,7 +186,7 @@
class="text-blue-500"
target="_blank"
>
here <fa icon="arrow-up-right-from-square" class="fa-fw" />
here <font-awesome icon="arrow-up-right-from-square" class="fa-fw" />
</a>
</div>
</div>
@@ -199,7 +199,7 @@
<p>
Of course, you'll want to back up all your data first -- all seeds as
well as the contacts & settings -- on the Profile
<fa icon="circle-user" /> page.
<font-awesome icon="circle-user" /> page.
</p>
<p>
Here are instructions to uninstall the app and clear out caches and storage.
@@ -246,8 +246,8 @@
<h2 class="text-xl font-semibold mt-4">Tests</h2>
<button
@click="showTestNotification()"
class="block w-full text-center text-md 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-2 rounded-md mt-4 mb-2"
@click="showTestNotification()"
>
Send Test Notification Directly to Device (Not Through Push Server)
</button>
@@ -259,8 +259,8 @@
</p>
<button
@click="alertWebPushSubscription()"
class="block w-full text-center text-md 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-2 rounded-md mt-4 mb-2"
@click="alertWebPushSubscription()"
>
Show Web Push Subscription Info
</button>
@@ -272,8 +272,8 @@
</p>
<button
@click="sendTestWebPushMessage(true)"
class="block w-full text-center text-md 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-2 rounded-md mt-4 mb-2"
@click="sendTestWebPushMessage(true)"
>
Send Yourself a Test Web Push Message (Through Push Server but Skipping
Client Filter)
@@ -285,8 +285,8 @@
</p>
<button
@click="sendTestWebPushMessage()"
class="block w-full text-center text-md 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-2 rounded-md mt-4 mb-2"
@click="sendTestWebPushMessage()"
>
Send Yourself a Test Web Push Message (Through Push Server and Client
Filter)
@@ -313,11 +313,12 @@ import { DIRECT_PUSH_TITLE, sendTestThroughPushServer } from "../libs/util";
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
import { db } from "../db/index";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { Router } from "vue-router";
import { logger } from "../utils/logger";
@Component({ components: { PushNotificationPermission, QuickNav } })
export default class HelpNotificationsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
subscriptionJSON?: PushSubscriptionJSON;
async mounted() {
@@ -326,7 +327,7 @@ export default class HelpNotificationsView extends Vue {
const fullSub = await registration?.pushManager.getSubscription();
this.subscriptionJSON = fullSub?.toJSON();
} catch (error) {
console.error("Mount error:", error);
logger.error("Mount error:", error);
}
}
@@ -368,7 +369,7 @@ export default class HelpNotificationsView extends Vue {
5000,
);
} catch (error) {
console.error("Got an error sending test notification:", error);
logger.error("Got an error sending test notification:", error);
this.$notify(
{
group: "alert",
@@ -401,7 +402,7 @@ export default class HelpNotificationsView extends Vue {
);
})
.catch((error) => {
console.error("Got a notification error:", error);
logger.error("Got a notification error:", error);
this.$notify(
{
group: "alert",

View File

@@ -19,13 +19,14 @@
:to="{ name: 'invite-one' }"
class="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-2 rounded-md"
>
<fa icon="envelope-open-text" class="fa-fw text-xl"
<font-awesome icon="envelope-open-text" class="fa-fw text-xl"
/></router-link>
</p>
<p>Then watch that page to see when they accept their invite.</p>
<p>
(That page is also reachable from the Contacts <fa icon="users" /> page
though the invitation <fa icon="envelope-open-text" /> icon.)
(That page is also reachable from the Contacts
<font-awesome icon="users" /> page though the invitation
<font-awesome icon="envelope-open-text" /> icon.)
</p>
<h1 class="mt-4 font-bold text-xl">Next Steps</h1>
@@ -35,12 +36,13 @@
<h1 class="font-bold text-xl">Without a backup, you can lose data.</h1>
<div>
<p>
Exporting backups (from the Account <fa icon="circle-user" /> screen)
is important for the case where they lose their device. This is
especially true for the Identifier Seed: that is theirs and and theirs
alone, and currently nobody else can recover it if they lose it. The
good thing is that anyone can create a new account and simply inform
their network of their new ID.
Exporting backups (from the Account
<font-awesome icon="circle-user" /> screen) is important for the case
where they lose their device. This is especially true for the
Identifier Seed: that is theirs and and theirs alone, and currently
nobody else can recover it if they lose it. The good thing is that
anyone can create a new account and simply inform their network of
their new ID.
</p>
</div>
</div>
@@ -54,7 +56,7 @@
<h1 class="font-bold text-xl">Add Contact & Register</h1>
<p>
You share even more information such as your picture and name when
you share with your QR code at these links: <fa icon="qrcode" />
you share with your QR code at these links: <font-awesome icon="qrcode" />
</p>
<p>
Scanning
@@ -70,14 +72,14 @@
</p>
<p>
2) Scan their QR, or have them tap on it to copy their info and send it to you.
Then you can add them to your Contacts <fa icon="users" />
Then you can add them to your Contacts <font-awesome icon="users" />
</p>
<p>
3) You can register them at their info page <fa icon="circle-info" />
and click on the register button <fa icon="person-circle-question" />
3) You can register them at their info page <font-awesome icon="circle-info" />
and click on the register button <font-awesome icon="person-circle-question" />
</p>
<p>
4) Add yourself to their Contacts <fa icon="users" />
4) Add yourself to their Contacts <font-awesome icon="users" />
</p>
</div>
@@ -94,9 +96,10 @@
<h1 class="font-bold text-xl">Enable Notifications</h1>
<div>
<p>
Enable notifications from the Account page <fa icon="circle-user" />.
Enable notifications from the Account page <font-awesome icon="circle-user" />.
Those notifications might show up on the device depending on your settings.
For the most reliable habits, set an alarm or do some other ritual to record gratitude every day.
For the most reliable habits, set an alarm or do some other ritual to record gratitude
every day.
</p>
</div>

View File

@@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw" />
<font-awesome icon="chevron-left" class="fa-fw" />
</h1>
</div>
@@ -30,8 +30,8 @@
<p class="ml-4">
If you'd like to see the page-by-page help,
<span
@click="unsetFinishedOnboarding()"
class="text-blue-500 cursor-pointer"
@click="unsetFinishedOnboarding()"
>click here</span>.
</p>
@@ -61,7 +61,7 @@
<h2 class="text-xl font-semibold">I want to know more because...</h2>
<ul class="list-disc list-outside ml-4">
<li class="p-2">
<div @click="showAlpha = !showAlpha" class="text-blue-500">... I'm a member of Alpha chat.</div>
<div class="text-blue-500" @click="showAlpha = !showAlpha">... I'm a member of Alpha chat.</div>
<div v-if="showAlpha">
<p>
This is a project for public benefit. You are invited to add your gratitude
@@ -98,7 +98,7 @@
</div>
</li>
<li class="p-2">
<div @click="showGroup = !showGroup" class="text-blue-500">... I want to find a group I'll enjoy working with.</div>
<div class="text-blue-500" @click="showGroup = !showGroup">... I want to find a group I'll enjoy working with.</div>
<div v-if="showGroup">
<p>
This app encourages people to offer small bits of time to one another. It's a way to
@@ -114,7 +114,7 @@
</div>
</li>
<li class="p-2">
<div @click="showCommunity = !showCommunity" class="text-blue-500">... I want to participate in community projects.</div>
<div class="text-blue-500" @click="showCommunity = !showCommunity">... I want to participate in community projects.</div>
<div v-if="showCommunity">
<p>
These are mostly at the beginning stages, so any of them will appreciate your offers that show interest.
@@ -127,7 +127,7 @@
</div>
</li>
<li class="p-2">
<div @click="showVerifiable = !showVerifiable" class="text-blue-500">... I want to build with verifiable, private data.</div>
<div class="text-blue-500" @click="showVerifiable = !showVerifiable">... I want to build with verifiable, private data.</div>
<div v-if="showVerifiable">
<p>
Make your claims and get others to confirm them. Then you can use the API to pull your copy of all that
@@ -153,7 +153,7 @@
</div>
</li>
<li class="p-2">
<div @click="showGovernance = !showGovernance" class="text-blue-500">... I want to build governance organically.</div>
<div class="text-blue-500" @click="showGovernance = !showGovernance">... I want to build governance organically.</div>
<div v-if="showGovernance">
<p>
This requires motivated, dedicated citizens. The good thing is that dedication the primary ingredient;
@@ -172,7 +172,7 @@
</div>
</li>
<li class="p-2">
<div @click="showBasics = !showBasics" class="text-blue-500">... I want to supply life's basics freely.</div>
<div class="text-blue-500" @click="showBasics = !showBasics">... I want to supply life's basics freely.</div>
<div v-if="showBasics">
<p>
This platform is not optimal for balancing needs and resources at this point,
@@ -191,7 +191,7 @@
<h2 class="text-xl font-semibold">How do I get started?</h2>
<p>
Someone -- like the person who told you about this app -- needs to register you
on the Contacts <fa icon="users" class="fa-fw" /> page.
on the Contacts <font-awesome icon="users" class="fa-fw" /> page.
If you heard about this from our outreach, feel free to contact us (below) for a chat.
After someone registers you, you can register others.
</p>
@@ -219,7 +219,7 @@
</p>
<p>
If they are not nearby to scan QR codes, you each can tap on the QR code
and paste it into the text box on the Contacts <fa icon="users" class="fa-fw" /> page.
and paste it into the text box on the Contacts <font-awesome icon="users" class="fa-fw" /> page.
</p>
<h2 class="text-xl font-semibold">
@@ -244,7 +244,7 @@
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page.
</li>
<li>
Click on "Backup Identifier Seed" and follow the instructions.
@@ -260,7 +260,7 @@
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page.
</li>
<li>
Click on "Download Settings...". That will save a file to your
@@ -274,7 +274,7 @@
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page,
tap on your image, and save it.
</li>
</ul>
@@ -315,7 +315,7 @@
</h2>
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
Beware that this will erase your existing contact & settings.
</li>
@@ -384,7 +384,7 @@
</h2>
<p>
There is an "Advanced" section at the bottom of the Profile
<fa icon="circle-user" /> page.
<font-awesome icon="circle-user" /> page.
</p>
<p>
There is even more functionality in a mobile app (and more
@@ -402,8 +402,8 @@
because they have not given you permission to see their information. Ask
them to add you to their contact list, and ask specifically to make sure
the eye next to your name is open like this
<fa icon="eye" class="fa-fw" /> and not closed like this
<fa icon="eye-slash" class="fa-fw" />.
<font-awesome icon="eye" class="fa-fw" /> and not closed like this
<font-awesome icon="eye-slash" class="fa-fw" />.
</p>
<p>
Sometimes the reason you don't see something is because the search
@@ -444,7 +444,7 @@
</li>
<li>
There may be a problem with your identity. Go to the Identity
<fa icon="circle-user" class="fa-fw" /> page, then "Advanced", and "Switch Identifier"
<font-awesome icon="circle-user" class="fa-fw" /> page, then "Advanced", and "Switch Identifier"
and you may see helpful info there. If it shows a problem, try adding your identifier again.
</li>
<li>
@@ -505,7 +505,7 @@
<ul class="list-disc list-outside ml-4">
<li>
If using notifications, a server stores push token data. That can be revoked at any time
by disabling notifications on the Profile <fa icon="circle-user" class="fa-fw" /> page.
by disabling notifications on the Profile <font-awesome icon="circle-user" class="fa-fw" /> page.
</li>
<li>
If sending images, a server stores them, too. They can be removed by editing the claim
@@ -529,17 +529,17 @@
If you have skills, contact us below.
If you have Bitcoin, donate to
<button
class="text-blue-500 ml-2"
@click="
doCopyTwoSecRedo(
'bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma',
() => (showDidCopy = !showDidCopy)
)
"
class="text-blue-500 ml-2"
>
bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma
<fa v-show="!showDidCopy" icon="copy" class="text-sm text-slate-400 fa-fw" />
<fa v-show="showDidCopy" icon="circle-check" class="text-sm text-green-500 fa-fw"/>
<font-awesome v-show="!showDidCopy" icon="copy" class="text-sm text-slate-400 fa-fw" />
<font-awesome v-show="showDidCopy" icon="circle-check" class="text-sm text-green-500 fa-fw"/>
</button>
You can donate online via
<a href="https://www.patreon.com/TimeSafari" target="_blank" class="text-blue-500">Patreon here</a>.
@@ -588,6 +588,7 @@ import {
@Component({ components: { QuickNav } })
export default class Help extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
package = Package;
commitHash = import.meta.env.VITE_GIT_HASH;
@@ -614,7 +615,7 @@ export default class Help extends Vue {
finishedOnboarding: false,
});
}
(this.$router as Router).push({ name: "home" });
this.$router.push({ name: "home" });
}
}
</script>

View File

@@ -10,7 +10,10 @@
<OnboardingDialog ref="onboardingDialog" />
<!-- prompt to install notifications with notificationsSupported, which we're making an advanced feature -->
<!--
prompt to install notifications with notificationsSupported, which we're making an advanced
feature
-->
<div class="mb-8 mt-8">
<div
v-if="false"
@@ -28,7 +31,7 @@
width="30"
style="display: inline; margin: 0 5px; vertical-align: middle"
/>and then "Add to Home Screen"
<fa icon="square-plus" title="Apple 'Add' icon" />
<font-awesome icon="square-plus" title="Apple 'Add' icon" />
and go click on that new app.
</span>
<span
@@ -36,12 +39,8 @@
>
You should see a prompt to install, or you can click on the
top-right dots
<fa
icon="ellipsis-vertical"
title="vertical ellipsis"
class="fa-fw"
/>
and then "Install"<img
<font-awesome icon="ellipsis-vertical" title="vertical ellipsis" />
/> and then "Install"<img
src="../assets/help/install-android-chrome.png"
alt="Android 'install' icon"
width="30"
@@ -73,14 +72,16 @@
<div class="mb-8">
<div v-if="isCreatingIdentifier">
<p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse" />
<font-awesome icon="spinner" class="fa-spin-pulse" />
Loading&hellip;
</p>
</div>
<div v-else>
<!-- !isCreatingIdentifier -->
<!-- They should have an identifier, even if it's an auto-generated one that they'll never use. -->
<!--
They should have an identifier, even if it's an auto-generated one that they'll never use.
-->
<div class="mb-4">
<div
v-if="!isRegistered"
@@ -91,8 +92,8 @@
To share, someone must register you.
<div class="block text-center">
<button
@click="showNameThenIdDialog()"
class="text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
@click="showNameThenIdDialog()"
>
Show them {{ PASSKEYS_ENABLED ? "default" : "your" }} identifier
info
@@ -116,10 +117,10 @@
<div class="flex">
<h2 class="text-xl font-bold">What have you seen someone do?</h2>
<button
@click="openGiftedPrompts()"
class="ml-2 block text-xs text-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 rounded-md"
@click="openGiftedPrompts()"
>
<fa icon="lightbulb" class="fa-fw" />
<font-awesome icon="lightbulb" class="fa-fw" />
</button>
</div>
@@ -147,7 +148,7 @@
>
<EntityIcon
:contact="contact"
:iconSize="64"
:icon-size="64"
class="mx-auto border border-blue-500 rounded-md mb-1 cursor-pointer"
/>
<h3
@@ -181,7 +182,7 @@
class="absolute right-6 bottom-0 transform translate-y-1/2 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
@click="openDialog()"
>
<fa icon="plus" class="fa-fw" />
<font-awesome icon="plus" class="fa-fw" />
</button>
</div>
@@ -192,12 +193,12 @@
Latest Activity
<button @click="openFeedFilters()">
<span class="text-xs text-white">
<fa
<font-awesome
v-if="resultsAreFiltered()"
icon="filter"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
/>
<fa
<font-awesome
v-else
icon="filter"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
@@ -208,8 +209,8 @@
</div>
<div
@click="goToActivityToUserPage()"
class="border-t p-2 border-slate-300"
@click="goToActivityToUserPage()"
>
<div class="flex justify-center">
<div
@@ -251,13 +252,13 @@
<InfiniteScroll @reached-bottom="loadMoreGives">
<ul id="listLatestActivity" class="border-t border-slate-300">
<li
class="border-b border-slate-300 py-2"
v-for="record in feedData"
:key="record.jwtId"
class="border-b border-slate-300 py-2"
>
<div
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="record.jwtId == feedLastViewedClaimId"
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
>
You've already seen all the following
</div>
@@ -265,7 +266,7 @@
<div class="grid grid-cols-12">
<span class="pt-1 col-span-1 justify-self-start">
<span>
<fa
<font-awesome
icon="circle-user"
:class="
computeKnownPersonIconStyleClassNames(
@@ -274,7 +275,7 @@
"
@click="toastUser('This involves your contacts.')"
/>
<fa
<font-awesome
icon="gift"
class="pl-3 text-slate-500"
@click="toastUser('This is a gift.')"
@@ -282,44 +283,11 @@
</span>
</span>
<span class="col-span-10 justify-self-stretch overflow-hidden">
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
<span
v-if="
record.giver.profileImageUrl ||
record.receiver.profileImageUrl
"
>
<EntityIcon
v-if="record.agentDid !== activeDid"
:icon-size="32"
:profile-image-url="record.giver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
<fa
v-if="
record.agentDid !== activeDid &&
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
icon="ellipsis"
class="text-slate"
/>
<EntityIcon
v-if="
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
:iconSize="32"
:profile-image-url="record.receiver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md ml-1"
/>
</span>
-->
<span class="pl-2 block break-words">
{{ giveDescription(record) }}
</span>
<a @click="onClickLoadClaim(record.jwtId)">
<fa
<font-awesome
icon="file-lines"
class="pl-2 text-slate-500 cursor-pointer"
/>
@@ -333,7 +301,7 @@
encodeURIComponent(record.fulfillsPlanHandleId)
"
>
<fa icon="hammer" class="text-blue-500" />
<font-awesome icon="hammer" class="text-blue-500" />
</router-link>
<router-link
v-if="record.providerPlanHandleId"
@@ -342,7 +310,7 @@
encodeURIComponent(record.providerPlanHandleId)
"
>
<fa icon="hammer" class="text-blue-500" />
<font-awesome icon="hammer" class="text-blue-500" />
</router-link>
</span>
</div>
@@ -364,7 +332,7 @@
</InfiniteScroll>
<div v-if="isFeedLoading">
<p class="text-slate-500 text-center italic mt-4 mb-4">
<fa icon="spinner" class="fa-spin-pulse" /> Loading&hellip;
<font-awesome icon="spinner" class="fa-spin-pulse" /> Loading&hellip;
</p>
</div>
<div v-if="!isFeedLoading && feedData.length === 0">
@@ -378,9 +346,9 @@
<ChoiceButtonDialog ref="choiceButtonDialog" />
<ImageViewer
v-model:is-open="isImageViewerOpen"
:image-url="selectedImage"
:image-data="selectedImageData"
v-model:is-open="isImageViewerOpen"
/>
</template>
@@ -427,17 +395,16 @@ import {
getNewOffersToUser,
getNewOffersToUserProjects,
getPlanFromCache,
GiveSummaryRecord,
} from "../libs/endorserServer";
import {
generateSaveAndActivateIdentity,
retrieveAccountDids,
GiverReceiverInputInfo,
OnboardPage,
registerSaveAndActivatePasskey,
} from "../libs/util";
import { GiveSummaryRecord } from "../interfaces";
interface GiveRecordWithContactInfo extends GiveSummaryRecord {
jwtId: string;
giver: {
displayName: string;
known: boolean;
@@ -452,7 +419,16 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
profileImageUrl?: string;
};
}
import { logger } from "../utils/logger";
/**
* HomeView - Main view component for the application's home page
*
* Workflow:
* 1. On mount, initializes user identity, settings, and data
* 2. Handles user registration status
* 3. Manages feed of activities and offers
* 4. Provides interface for creating and viewing claims
*/
@Component({
components: {
EntityIcon,
@@ -506,127 +482,208 @@ export default class HomeView extends Vue {
isImageViewerOpen = false;
imageCache: Map<string, Blob | null> = new Map();
/**
* Initializes the component on mount
* Sequence:
* 1. Initialize identity (create if needed)
* 2. Load user settings
* 3. Load contacts
* 4. Check registration status
* 5. Load feed data
* 6. Load new offers
* 7. Check onboarding status
*/
async mounted() {
try {
try {
this.allMyDids = await retrieveAccountDids();
if (this.allMyDids.length === 0) {
this.isCreatingIdentifier = true;
const newDid = await generateSaveAndActivateIdentity();
this.isCreatingIdentifier = false;
this.allMyDids = [newDid];
}
} catch (error) {
// continue because we want the feed to work, even anonymously
logConsoleAndDb(
"Error retrieving all account DIDs on home page:" + error,
true,
);
// some other piece will display an error about personal info
await this.initializeIdentity();
await this.loadSettings();
await this.loadContacts();
await this.checkRegistrationStatus();
await this.loadFeedData();
await this.loadNewOffers();
await this.checkOnboarding();
} catch (err: unknown) {
this.handleError(err);
}
}
/**
* Initializes user identity
* - Retrieves existing DIDs
* - Creates new DID if none exists
* @throws Logs error if DID retrieval fails
*/
private async initializeIdentity() {
try {
this.allMyDids = await retrieveAccountDids();
if (this.allMyDids.length === 0) {
this.isCreatingIdentifier = true;
const newDid = await generateSaveAndActivateIdentity();
this.isCreatingIdentifier = false;
this.allMyDids = [newDid];
}
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.feedLastViewedClaimId = settings.lastViewedClaimId;
this.givenName = settings.firstName || "";
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
this.isRegistered = !!settings.isRegistered;
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId;
this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc;
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Home,
);
}
// someone may have have registered after sharing contact info, so recheck
if (!this.isRegistered && this.activeDid) {
try {
const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
this.activeDid,
);
if (resp.status === 200) {
await updateAccountSettings(this.activeDid, {
isRegistered: true,
});
this.isRegistered = true;
}
} catch (e) {
// ignore the error... just keep us unregistered
}
}
// this returns a Promise but we don't need to wait for it
this.updateAllFeed();
if (this.activeDid) {
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
);
this.numNewOffersToUser = offersToUserData.data.length;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
}
if (this.activeDid) {
const offersToUserProjects = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
err.userMessage ||
"There was an error retrieving your settings or the latest activity.",
},
5000,
} catch (error) {
logConsoleAndDb(
"Error retrieving all account DIDs on home page:" + error,
true,
);
}
}
async generatePasskeyIdentifier() {
this.isCreatingIdentifier = true;
const account = await registerSaveAndActivatePasskey(
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : ""),
);
this.activeDid = account.did;
this.allMyDids = this.allMyDids.concat(this.activeDid);
this.isCreatingIdentifier = false;
/**
* Loads user settings from storage
* Sets component state for:
* - API server
* - Active DID
* - Feed filters and view settings
* - Registration status
* - Notification acknowledgments
*/
private async loadSettings() {
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.feedLastViewedClaimId = settings.lastViewedClaimId;
this.givenName = settings.firstName || "";
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
this.isRegistered = !!settings.isRegistered;
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId;
this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc;
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
}
/**
* Loads user contacts from database
* Used for displaying contact info in feed and actions
*/
private async loadContacts() {
this.allContacts = await db.contacts.toArray();
}
/**
* Verifies user registration status with endorser service
* - Checks if unregistered user can access API
* - Updates registration status if successful
* - Preserves unregistered state on failure
*/
private async checkRegistrationStatus() {
if (!this.isRegistered && this.activeDid) {
try {
const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
this.activeDid,
);
if (resp.status === 200) {
await updateAccountSettings(this.activeDid, {
apiServer: this.apiServer,
isRegistered: true,
...(await retrieveSettingsForActiveAccount()),
});
this.isRegistered = true;
}
} catch (e) {
// ignore the error... just keep us unregistered
}
}
}
/**
* Initializes feed data
* Triggers updateAllFeed() to populate activity feed
*/
private async loadFeedData() {
await this.updateAllFeed();
}
/**
* Loads new offers for user and their projects
* Updates:
* - Number of new direct offers
* - Number of new project offers
* - Rate limit status for both
* @requires Active DID
*/
private async loadNewOffers() {
if (this.activeDid) {
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
);
this.numNewOffersToUser = offersToUserData.data.length;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
const offersToUserProjects = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
}
}
/**
* Checks if user needs onboarding
* Opens onboarding dialog if not completed
*/
private async checkOnboarding() {
const settings = await retrieveSettingsForActiveAccount();
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(OnboardPage.Home);
}
}
/**
* Handles errors during initialization
* - Logs error to console and database
* - Displays user notification
* @param err Error object with optional userMessage
*/
private handleError(err: unknown) {
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
err.userMessage ||
"There was an error retrieving your settings or the latest activity.",
},
5000,
);
}
/**
* Checks if feed results are being filtered
* @returns true if visible or nearby filters are active
*/
resultsAreFiltered() {
return this.isFeedFilteredByVisible || this.isFeedFilteredByNearby;
}
/**
* Checks if browser notifications are supported
* @returns true if Notification API is available
*/
notificationsSupported() {
return "Notification" in window;
}
// only called when a setting was changed
/**
* Reloads feed when filter settings change
* - Updates filter states
* - Clears existing feed data
* - Triggers new feed load
*/
async reloadFeedOnChange() {
const settings = await retrieveSettingsForActiveAccount();
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
@@ -639,9 +696,9 @@ export default class HomeView extends Vue {
}
/**
* Data loader used by infinite scroller
* @param payload is the flag from the InfiniteScroll indicating if it should load
**/
* Loads more feed items for infinite scroll
* @param payload Boolean indicating if more items should be loaded
*/
async loadMoreGives(payload: boolean) {
// Since feed now loads projects along the way, it takes longer
// and the InfiniteScroll component triggers a load before finished.
@@ -664,6 +721,12 @@ export default class HomeView extends Vue {
}
}
/**
* Updates feed with latest activity
* - Handles filtering of results
* - Updates last viewed claim ID
* - Manages loading state
*/
async updateAllFeed() {
this.isFeedLoading = true;
let endOfResults = true;
@@ -731,6 +794,7 @@ export default class HomeView extends Vue {
const newRecord: GiveRecordWithContactInfo = {
...record,
jwtId: record.jwtId,
giver: didInfoForContact(
giverDid,
this.activeDid,
@@ -765,7 +829,7 @@ export default class HomeView extends Vue {
}
})
.catch((e) => {
console.error("Error with feed load:", e);
logger.error("Error with feed load:", e);
this.$notify(
{
group: "alert",
@@ -919,6 +983,11 @@ export default class HomeView extends Vue {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
/**
* Opens dialog for creating new gift/claim
* @param giver Optional contact info for giver
* @param description Optional gift description
*/
openDialog(giver?: GiverReceiverInputInfo, description?: string) {
(this.$refs.customDialog as GiftedDialog).open(
giver,
@@ -932,12 +1001,20 @@ export default class HomeView extends Vue {
);
}
/**
* Opens prompts for gift ideas
* Links to openDialog for selected prompt
*/
openGiftedPrompts() {
(this.$refs.giftedPrompts as GiftedPrompts).open((giver, description) =>
this.openDialog(giver as GiverReceiverInputInfo, description),
);
}
/**
* Opens feed filter configuration
* @param reloadFeedOnChange Callback for when filters are updated
*/
openFeedFilters() {
(this.$refs.feedFilters as FeedFilters).open(this.reloadFeedOnChange);
}
@@ -993,7 +1070,7 @@ export default class HomeView extends Vue {
// The Web Share API will handle sharing the URL appropriately
this.imageCache.set(imageUrl, null);
} catch (error) {
console.warn("Failed to cache image:", error);
logger.warn("Failed to cache image:", error);
}
}

View File

@@ -8,7 +8,7 @@
<router-link
:to="{ name: 'account' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><fa icon="chevron-left" class="fa-fw"></fa>
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Switch Identity
@@ -22,7 +22,10 @@
v-if="activeDid && !activeDidInIdentities"
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4"
>
<fa icon="circle-check" class="fa-fw text-red-600 text-xl mr-3"></fa>
<font-awesome
icon="circle-check"
class="fa-fw text-red-600 text-xl mr-3"
></font-awesome>
<div class="text-sm text-slate-500">
<div class="overflow-hidden truncate">
<b>ID:</b> <code>{{ activeDid }}</code>
@@ -45,12 +48,12 @@
class="flex flex-grow items-center bg-slate-100 rounded-md px-4 py-3 mb-2 truncate cursor-pointer"
@click="switchAccount(ident.did)"
>
<fa
<font-awesome
v-if="ident.did === activeDid"
icon="circle-check"
class="fa-fw text-blue-600 text-xl mr-3"
/>
<fa
<font-awesome
v-else
icon="circle"
class="fa-fw text-slate-400 text-xl mr-3"
@@ -62,13 +65,13 @@
</span>
</div>
<div>
<fa
<font-awesome
v-if="ident.did === activeDid"
icon="trash-can"
class="text-slate-400 text-xl ml-2 mr-2 cursor-pointer"
@click="notifyCannotDelete()"
/>
<fa
<font-awesome
v-else
icon="trash-can"
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
@@ -110,10 +113,11 @@ import {
} from "../db/index";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { retrieveAllAccountsMetadata } from "../libs/util";
import { logger } from "../utils/logger";
@Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
public activeDid = "";
public activeDidInIdentities = false;
@@ -131,7 +135,10 @@ export default class IdentitySwitcherView extends Vue {
const accounts = await retrieveAllAccountsMetadata();
for (let n = 0; n < accounts.length; n++) {
const acct = accounts[n];
this.otherIdentities.push({ id: acct.id as string, did: acct.did });
this.otherIdentities.push({
id: (acct.id ?? 0).toString(),
did: acct.did,
});
if (acct.did && this.activeDid === acct.did) {
this.activeDidInIdentities = true;
}
@@ -146,7 +153,7 @@ export default class IdentitySwitcherView extends Vue {
},
5000,
);
console.error("Telling user to clear cache at page create because:", err);
logger.error("Telling user to clear cache at page create because:", err);
}
}
@@ -159,7 +166,7 @@ export default class IdentitySwitcherView extends Vue {
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did,
});
(this.$router as Router).push({ name: "account" });
this.$router.push({ name: "account" });
}
async deleteAccount(id: string) {

View File

@@ -5,10 +5,10 @@
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<fa icon="chevron-left"></fa>
<font-awesome icon="chevron-left"></font-awesome>
</button>
Import Existing Identifier
</h1>
@@ -20,10 +20,10 @@
<!-- id used by puppeteer test script -->
<textarea
id="seed-input"
v-model="mnemonic"
type="text"
placeholder="Seed Phrase"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="mnemonic"
/>
<h3
@@ -35,22 +35,22 @@
<div v-if="showAdvanced">
Enter a custom derivation path
<input
v-model="derivationPath"
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
v-model="derivationPath"
/>
<span class="ml-4">
For previous uPort or Endorser users,
<a
@click="derivationPath = UPORT_DERIVATION_PATH"
class="text-blue-500"
@click="derivationPath = UPORT_DERIVATION_PATH"
>
click here to use that value.
</a>
</span>
<div class="mt-4" v-if="numAccounts == 1">
<input type="checkbox" class="mr-2" v-model="shouldErase" />
<div v-if="numAccounts == 1" class="mt-4">
<input v-model="shouldErase" type="checkbox" class="mr-2" />
<label>Erase the previous identifier.</label>
</div>
@@ -65,15 +65,15 @@
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
@click="fromMnemonic()"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="fromMnemonic()"
>
Import
</button>
<button
@click="onCancelClick()"
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="onCancelClick()"
>
Cancel
</button>
@@ -99,7 +99,7 @@ import {
newIdentifier,
} from "../libs/crypto";
import { retrieveAccountCount } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: {},
})
@@ -111,6 +111,7 @@ export default class ImportAccountView extends Vue {
AppString = AppString;
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
apiServer = "";
address = "";
@@ -130,7 +131,7 @@ export default class ImportAccountView extends Vue {
}
public onCancelClick() {
(this.$router as Router).back();
this.$router.back();
}
public isNotProdServer() {
@@ -170,10 +171,10 @@ export default class ImportAccountView extends Vue {
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
(this.$router as Router).push({ name: "account" });
this.$router.push({ name: "account" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error saving mnemonic & updating settings:", err);
logger.error("Error saving mnemonic & updating settings:", err);
if (err == "Error: invalid mnemonic") {
this.$notify(
{

View File

@@ -5,10 +5,10 @@
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<fa icon="chevron-left"></fa>
<font-awesome icon="chevron-left"></font-awesome>
</button>
Derive from Existing Identity
</h1>
@@ -25,21 +25,21 @@
</p>
<ul class="mb-4">
<li
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
v-for="dids in didArrays"
:key="dids[0]"
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
@click="switchAccount(dids[0])"
>
<fa
<font-awesome
v-if="dids[0] == selectedArrayFirstDid"
icon="circle"
class="fa-fw text-blue-500 text-xl mr-3"
></fa>
<fa
></font-awesome>
<font-awesome
v-else
icon="circle"
class="fa-fw text-slate-400 text-xl mr-3"
></fa>
></font-awesome>
<span class="overflow-hidden">
<div class="text-sm text-slate-500 truncate">
<code>{{ dids.join(",") }}</code>
@@ -51,15 +51,15 @@
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
@click="incrementDerivation()"
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="incrementDerivation()"
>
Increment and Import
</button>
<button
@click="onCancelClick()"
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="onCancelClick()"
>
Cancel
</button>
@@ -70,7 +70,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import {
DEFAULT_ROOT_DERIVATION_PATH,
@@ -81,11 +81,14 @@ import {
import { accountsDBPromise, db } from "../db/index";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { retrieveAllFullyDecryptedAccounts } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: {},
})
export default class ImportAccountView extends Vue {
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
didArrays: Array<Array<string>> = [];
selectedArrayFirstDid = "";
@@ -102,7 +105,7 @@ export default class ImportAccountView extends Vue {
}
public onCancelClick() {
(this.$router as Router).back();
this.$router.back();
}
public switchAccount(did: string) {
@@ -151,9 +154,9 @@ export default class ImportAccountView extends Vue {
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: newId.did,
});
(this.$router as Router).push({ name: "account" });
this.$router.push({ name: "account" });
} catch (err) {
console.error("Error saving mnemonic & updating settings:", err);
logger.error("Error saving mnemonic & updating settings:", err);
}
}
}

View File

@@ -5,7 +5,7 @@
v-if="checkingInvite"
class="text-lg text-center font-light relative px-7"
>
<fa icon="spinner" class="fa-spin-pulse" />
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else class="text-center mt-4">
<p>That invitation did not work.</p>
@@ -28,8 +28,8 @@
/>
<br />
<button
@click="() => processInvite(inputJwt, true)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
@click="() => processInvite(inputJwt, true)"
>
Accept
</button>
@@ -39,7 +39,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { Router, RouteLocationNormalized } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER, NotificationIface } from "../constants/app";
@@ -52,18 +52,69 @@ import { decodeEndorserJwt } from "../libs/crypto/vc";
import { errorStringForLog } from "../libs/endorserServer";
import { generateSaveAndActivateIdentity } from "../libs/util";
@Component({ components: { QuickNav } })
/**
* Invite One Accept View Component
* @author Matthew Raymer
*
* This component handles accepting single-use invitations to join the platform.
* It supports multiple invitation formats and provides user feedback during the process.
*
* Workflow:
* 1. Component loads with JWT from route or user input
* 2. Validates JWT format and signature
* 3. Processes invite data and redirects to contacts page
* 4. Handles errors with user feedback
*
* Supported Invite Formats:
* 1. Direct JWT in URL path: /invite-one-accept/{jwt}
* 2. JWT in text message URL: https://app.example.com/invite-one-accept/{jwt}
* 3. JWT surrounded by other text: "Your invite code is {jwt}"
*
* Security Features:
* - JWT validation
* - Identity generation if needed
* - Error handling for invalid/expired invites
*
* @see ContactsView for completion of invite process
*/
@Component({
components: { QuickNav },
})
export default class InviteOneAcceptView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void;
/** Router instance for navigation */
$router!: Router;
/** Route instance for current route */
$route!: RouteLocationNormalized;
activeDid: string = "";
apiServer: string = "";
checkingInvite: boolean = true;
inputJwt: string = "";
/** Active user's DID */
activeDid = "";
/** API server endpoint */
apiServer = "";
/** Loading state for invite processing */
checkingInvite = true;
/** User input for manual JWT entry */
inputJwt = "";
/**
* Component lifecycle hook that initializes invite processing
*
* Workflow:
* 1. Opens database connection
* 2. Retrieves account settings
* 3. Ensures active DID exists or generates one
* 4. Extracts JWT from URL path
* 5. Processes invite automatically
*
* @throws Will not throw but logs errors
* @emits Notifications on errors
*/
async mounted() {
this.checkingInvite = true;
await db.open();
// Load or generate identity
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
@@ -72,81 +123,155 @@ export default class InviteOneAcceptView extends Vue {
this.activeDid = await generateSaveAndActivateIdentity();
}
const jwt = window.location.pathname.substring(
"/invite-one-accept/".length,
);
// Extract JWT from route path
const jwt = (this.$route.params.jwt as string) || "";
await this.processInvite(jwt, false);
this.checkingInvite = false;
}
// process the invite JWT and/or text message containing the URL with the JWT
/**
* Processes an invite JWT and/or text containing the invite
*
* Handles multiple input formats:
* 1. Direct JWT:
* - Raw JWT string starting with "ey"
* - Example: eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ...
*
* 2. URL containing JWT:
* - Full URL with JWT in path
* - Pattern: /invite-one-accept/{jwt}
* - Example: https://app.example.com/invite-one-accept/eyJ0eXAiOiJKV1Q...
*
* 3. Text with embedded JWT:
* - JWT surrounded by other text
* - Uses regex to extract JWT pattern
* - Example: "Your invite code is eyJ0eXAiOiJKV1Q... Click to accept"
*
* Extraction Process:
* 1. First attempts URL pattern match
* 2. If no URL found, looks for JWT pattern (ey...)
* 3. Validates extracted JWT format
* 4. Redirects to contacts page on success
*
* Error Handling:
* - Missing JWT: Shows "Missing Invite" notification
* - Invalid JWT: Logs error and shows generic error message
* - Network Issues: Captured in try/catch block
*
* @param jwtInput Raw input that may contain a JWT
* @param notifyOnFailure Whether to show error notifications
* - true: Shows UI notifications for errors
* - false: Silently logs errors (used for auto-processing)
* @throws Will not throw but logs errors
* @emits Notifications on errors if notifyOnFailure is true
* @emits Router navigation on success to /contacts?inviteJwt={jwt}
*/
async processInvite(jwtInput: string, notifyOnFailure: boolean) {
this.checkingInvite = true;
try {
let jwt: string = jwtInput ?? "";
// parse the string: extract the URL or JWT if surrounded by spaces
// and then extract the JWT from the URL
// (For another approach used with contacts, see getContactPayloadFromJwtUrl)
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch && urlMatch[1]) {
// extract the JWT from the URL, meaning any character except "?"
const internalMatch = urlMatch[1].match(/\/invite-one-accept\/([^?]+)/);
if (internalMatch && internalMatch[1]) {
jwt = internalMatch[1];
}
} else {
// extract the JWT (which starts with "ey") if it is surrounded by other input
const spaceMatch = jwtInput.match(/(ey[\w.-]+)/);
if (spaceMatch && spaceMatch[1]) {
jwt = spaceMatch[1];
}
}
const jwt = this.extractJwtFromInput(jwtInput);
if (!jwt) {
if (notifyOnFailure) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the data.",
},
5000,
);
}
} else {
//const payload: JWTPayload =
decodeEndorserJwt(jwt);
this.handleMissingJwt(notifyOnFailure);
return;
}
// That's good enough for an initial check.
// Send them to the contacts page to finish, with inviteJwt in the query string.
(this.$router as Router).push({
name: "contacts",
query: { inviteJwt: jwt },
});
}
await this.validateAndRedirect(jwt);
} catch (error) {
const fullError = "Error accepting invite: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
if (notifyOnFailure) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing that invite.",
},
3000,
);
}
this.handleError(error, notifyOnFailure);
} finally {
this.checkingInvite = false;
}
this.checkingInvite = false;
}
// check the invite JWT
/**
* Extracts JWT from various input formats
* @param input Raw input text
* @returns Extracted JWT or empty string
*/
private extractJwtFromInput(input: string): string {
const jwtInput = input ?? "";
// Try URL format first
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch?.[1]) {
const internalMatch = urlMatch[1].match(/\/invite-one-accept\/([^?]+)/);
if (internalMatch?.[1]) return internalMatch[1];
}
// Try direct JWT format
const spaceMatch = jwtInput.match(/(ey[\w.-]+)/);
if (spaceMatch?.[1]) return spaceMatch[1];
return "";
}
/**
* Validates JWT and redirects to contacts page
* @param jwt JWT to validate
*/
private async validateAndRedirect(jwt: string) {
decodeEndorserJwt(jwt);
this.$router.push({
name: "contacts",
query: { inviteJwt: jwt },
});
}
/**
* Handles missing JWT error
* @param notify Whether to show notification
*/
private handleMissingJwt(notify: boolean) {
if (notify) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the data.",
},
5000,
);
}
}
/**
* Handles processing errors
* @param error Error that occurred
* @param notify Whether to show notification
*/
private handleError(error: unknown, notify: boolean) {
const fullError = "Error accepting invite: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
if (notify) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing that invite.",
},
3000,
);
}
}
/**
* Validates invite data format
*
* Checks for common error cases:
* - Truncated URLs
* - Missing JWT data
* - Invalid URL formats
*
* @param jwtInput Raw input to validate
* @throws Will not throw but shows notifications
* @emits Notifications on validation errors
*/
async checkInvite(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||

View File

@@ -9,7 +9,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -40,7 +40,7 @@
class="fixed right-6 top-12 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
@click="createInvite()"
>
<fa icon="plus" class="fa-fw"></fa>
<font-awesome icon="plus" class="fa-fw"></font-awesome>
</button>
<InviteDialog ref="inviteDialog" />
@@ -72,16 +72,18 @@
!invite.redeemedAt &&
invite.expiresAt > new Date().toISOString()
"
class="text-center text-blue-500 cursor-pointer"
:title="inviteLink(invite.jwt)"
@click="
copyInviteAndNotify(invite.inviteIdentifier, invite.jwt)
"
class="text-center text-blue-500 cursor-pointer"
:title="inviteLink(invite.jwt)"
>
{{ getTruncatedInviteId(invite.inviteIdentifier) }}
</span>
<span
v-else
class="text-center text-slate-500 cursor-pointer"
:title="inviteLink(invite.jwt)"
@click="
showInvite(
invite.inviteIdentifier,
@@ -89,8 +91,6 @@
invite.expiresAt < new Date().toISOString(),
)
"
class="text-center text-slate-500 cursor-pointer"
:title="inviteLink(invite.jwt)"
>
{{ getTruncatedInviteId(invite.inviteIdentifier) }}
</span>
@@ -106,7 +106,7 @@
<br />
{{ getTruncatedRedeemedBy(invite.redeemedBy) }}
<br />
<fa
<font-awesome
v-if="invite.redeemedBy && !contactsRedeemed[invite.redeemedBy]"
icon="plus"
class="bg-green-600 text-white px-1 py-1 rounded-full cursor-pointer"
@@ -114,7 +114,7 @@
/>
</td>
<td>
<fa
<font-awesome
icon="trash-can"
class="text-red-600 text-xl ml-2 mr-2 cursor-pointer"
@click="deleteInvite(invite.inviteIdentifier, invite.notes)"
@@ -132,6 +132,7 @@
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router";
import ContactNameDialog from "../components/ContactNameDialog.vue";
import QuickNav from "../components/QuickNav.vue";
@@ -141,7 +142,7 @@ import { APP_SERVER, AppString, NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { createInviteJwt, getHeaders } from "../libs/endorserServer";
import { logger } from "../utils/logger";
interface Invite {
inviteIdentifier: string;
expiresAt: string;
@@ -156,6 +157,7 @@ interface Invite {
})
export default class InviteOneView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
invites: Invite[] = [];
activeDid: string = "";
@@ -189,7 +191,7 @@ export default class InviteOneView extends Vue {
}
}
} catch (error) {
console.error("Error fetching invites:", error);
logger.error("Error fetching invites:", error);
this.$notify(
{
group: "alert",
@@ -256,7 +258,7 @@ export default class InviteOneView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lookForErrorAndNotify(error: any, title: string, defaultMessage: string) {
console.error(title, "-", error);
logger.error(title, "-", error);
let message = defaultMessage;
if (error.response && error.response.data && error.response.data.error) {
if (error.response.data.error.message) {

View File

@@ -6,10 +6,10 @@
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<fa
<font-awesome
icon="chevron-left"
@click="$router.back()"
class="fa-fw text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
/>
New Activity For You
</h1>
@@ -25,7 +25,7 @@
<span class="text-lg font-medium ml-4"
>New Offer{{ newOffersToUser.length === 1 ? "" : "s" }} To You</span
>
<fa
<font-awesome
v-if="newOffersToUser.length > 0"
:icon="showOffersDetails ? 'chevron-down' : 'chevron-right'"
class="cursor-pointer ml-4 mr-4 text-lg"
@@ -59,12 +59,15 @@
:to="{ path: '/claim/' + encodeURIComponent(offer.jwtId) }"
class="text-blue-500"
>
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" />
<font-awesome
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
/>
</router-link>
<!-- New line that appears on hover or when the offer is clicked -->
<div
@click="markOffersAsReadStartingWith(offer.jwtId)"
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="markOffersAsReadStartingWith(offer.jwtId)"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers
@@ -87,7 +90,7 @@
>New Offer{{ newOffersToUserProjects.length === 1 ? "" : "s" }} To
Your Projects</span
>
<fa
<font-awesome
v-if="newOffersToUserProjects.length > 0"
:icon="
showOffersToUserProjectsDetails ? 'chevron-down' : 'chevron-right'
@@ -125,12 +128,15 @@
:to="{ path: '/claim/' + encodeURIComponent(offer.jwtId) }"
class="text-blue-500"
>
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" />
<font-awesome
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
/>
</router-link>
<!-- New line that appears on hover -->
<div
@click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)"
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers
@@ -154,13 +160,13 @@ import {
updateAccountSettings,
} from "../db/index";
import { Contact } from "../db/tables/contacts";
import { Router } from "vue-router";
import { OfferSummaryRecord, OfferToPlanSummaryRecord } from "../interfaces";
import {
didInfo,
displayAmount,
getNewOffersToUser,
getNewOffersToUserProjects,
OfferSummaryRecord,
OfferToPlanSummaryRecord,
} from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
@@ -169,7 +175,7 @@ import { retrieveAccountDids } from "../libs/util";
})
export default class NewActivityView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
@@ -218,7 +224,7 @@ export default class NewActivityView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings & contacts:", err);
logger.error("Error retrieving settings & contacts:", err);
this.$notify(
{
group: "alert",

View File

@@ -5,20 +5,20 @@
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Edit Identity
</h1>
</div>
<input
v-model="givenName"
type="text"
placeholder="Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="givenName"
/>
<div class="mt-8">
@@ -54,6 +54,8 @@ import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
components: {},
})
export default class NewEditAccountView extends Vue {
$router!: Router;
givenName = "";
// 'created' hook runs when the Vue instance is first created
@@ -69,11 +71,11 @@ export default class NewEditAccountView extends Vue {
firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3
});
(this.$router as Router).back();
this.$router.back();
}
onClickCancel() {
(this.$router as Router).back();
this.$router.back();
}
}
</script>

View File

@@ -8,10 +8,10 @@
<!-- Cancel -->
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Edit Project Idea
</h1>
@@ -25,10 +25,10 @@
</div>
<input
v-model="fullClaim.name"
type="text"
placeholder="Idea Name"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
v-model="fullClaim.name"
/>
<div class="flex justify-center mt-4">
@@ -36,14 +36,14 @@
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
</a>
<fa
<font-awesome
icon="trash-can"
@click="confirmDeleteImage"
class="text-red-500 fa-fw ml-8 mt-10"
@click="confirmDeleteImage"
/>
</span>
<span v-else>
<fa
<font-awesome
icon="camera"
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 px-2 py-2 rounded-md"
@click="openImageDialog"
@@ -53,27 +53,27 @@
<ImageMethodDialog ref="imageDialog" />
<input
v-model="agentDid"
type="text"
placeholder="Other Authorized Representative"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
v-model="agentDid"
/>
<div class="mb-4">
<p v-if="activeDid != projectIssuerDid && agentDid != projectIssuerDid">
<span class="text-red-500">Beware!</span>
If you save this, the original project owner will no longer be able to
edit it.
<button @click="agentDid = projectIssuerDid" class="text-blue-500">
<button class="text-blue-500" @click="agentDid = projectIssuerDid">
Click here to make the original owner an authorized representative.
</button>
</p>
</div>
<textarea
v-model="fullClaim.description"
placeholder="Description"
class="block w-full rounded border border-slate-400 px-3 py-2"
rows="5"
v-model="fullClaim.description"
maxlength="5000"
></textarea>
<div class="text-xs text-slate-500 italic">
@@ -102,9 +102,9 @@
class="rounded border border-slate-400 px-3 py-2"
/>
<input
v-model="startTimeInput"
:disabled="!startDateInput"
placeholder="Start Time"
v-model="startTimeInput"
type="time"
class="rounded border border-slate-400 ml-2 px-3 py-2"
/>
@@ -127,9 +127,9 @@
class="ml-2 rounded border border-slate-400 px-3 py-2"
/>
<input
v-model="endTimeInput"
:disabled="!endDateInput"
placeholder="End Time"
v-model="endTimeInput"
type="time"
class="rounded border border-slate-400 ml-2 px-3 py-2"
/>
@@ -140,7 +140,7 @@
class="flex items-center mt-4"
@click="includeLocation = !includeLocation"
>
<input type="checkbox" class="mr-2" v-model="includeLocation" />
<input v-model="includeLocation" type="checkbox" class="mr-2" />
<label for="includeLocation">Include Location</label>
</div>
<div v-if="includeLocation" class="mb-4 aspect-video">
@@ -179,21 +179,14 @@
class="items-center mb-4"
>
<div class="flex" @click="sendToTrustroots = !sendToTrustroots">
<input type="checkbox" class="mr-2" v-model="sendToTrustroots" />
<input v-model="sendToTrustroots" type="checkbox" class="mr-2" />
<label>Send to Trustroots</label>
<fa
<font-awesome
icon="circle-info"
class="text-blue-500 ml-2 cursor-pointer"
@click.stop="showNostrPartnerInfo"
/>
</div>
<!--
<div class="flex" @click="sendToTripHopping = !sendToTripHopping">
<input type="checkbox" class="mr-2" v-model="sendToTripHopping" />
<label>Send to TripHopping</label>
<fa icon="circle-info" class="text-blue-500 ml-2 cursor-pointer" @click.stop="showNostrPartnerInfo" />
</div>
-->
</div>
<div class="mt-8">
@@ -229,10 +222,11 @@
import "leaflet/dist/leaflet.css";
import { AxiosError, AxiosRequestHeaders } from "axios";
import { DateTime } from "luxon";
import { finalizeEvent, serializeEvent } from "nostr-tools";
// these core imports could also be included as "import type ..."
import { EventTemplate, UnsignedEvent, VerifiedEvent } from "nostr-tools/core";
import * as nip06 from "nostr-tools/nip06";
import { finalizeEvent } from "nostr-tools/lib/esm/index.js";
import {
accountFromExtendedKey,
extendedKeysFromSeedWords,
} from "nostr-tools/lib/esm/nip06.js";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
@@ -245,21 +239,30 @@ import {
NotificationIface,
} from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { PlanVerifiableCredential } from "../interfaces";
import {
createEndorserJwtVcFromClaim,
getHeaders,
PlanVerifiableCredential,
} from "../libs/endorserServer";
import {
retrieveAccountCount,
retrieveFullyDecryptedAccount,
} from "../libs/util";
import {
EventTemplate,
UnsignedEvent,
VerifiedEvent,
} from "nostr-tools/lib/esm/index.js";
import { serializeEvent } from "nostr-tools/lib/esm/index.js";
import { logger } from "../utils/logger";
@Component({
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
})
export default class NewEditProjectView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
errNote(message: string) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
@@ -305,8 +308,7 @@ export default class NewEditProjectView extends Vue {
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId =
(this.$route as RouteLocationNormalizedLoaded).query["projectId"] || "";
this.projectId = (this.$route.query["projectId"] as string) || "";
if (this.projectId) {
if (this.numAccounts === 0) {
@@ -355,7 +357,7 @@ export default class NewEditProjectView extends Vue {
}
}
} catch (error) {
console.error("Got error retrieving that project", error);
logger.error("Got error retrieving that project", error);
this.errNote("There was an error retrieving that project.");
}
}
@@ -389,7 +391,7 @@ export default class NewEditProjectView extends Vue {
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
console.log(
logger.log(
"Using shared image API server, so only users on that server can play with images.",
);
}
@@ -403,7 +405,7 @@ export default class NewEditProjectView extends Vue {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
console.error("Problem deleting image:", response);
logger.error("Problem deleting image:", response);
this.$notify(
{
group: "alert",
@@ -418,10 +420,10 @@ export default class NewEditProjectView extends Vue {
this.imageUrl = "";
} catch (error) {
console.error("Error deleting image:", error);
logger.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).response.status === 404) {
console.log("The image was already deleted:", error);
logger.log("The image was already deleted:", error);
this.imageUrl = "";
@@ -591,12 +593,9 @@ export default class NewEditProjectView extends Vue {
}
}
(this.$router as Router).push({ path: "/project/" + projectPath });
this.$router.push({ path: "/project/" + projectPath });
} else {
console.error(
"Got unexpected 'data' inside response from server",
resp,
);
logger.error("Got unexpected 'data' inside response from server", resp);
this.$notify(
{
group: "alert",
@@ -613,7 +612,7 @@ export default class NewEditProjectView extends Vue {
error?: { message?: string };
}>;
if (serverError) {
console.error("Got error from server", serverError);
logger.error("Got error from server", serverError);
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
userMessage =
(serverError.response?.data?.error?.message as string) ||
@@ -639,7 +638,7 @@ export default class NewEditProjectView extends Vue {
);
}
} else {
console.error("Here's the full error trying to save the claim:", error);
logger.error("Here's the full error trying to save the claim:", error);
this.$notify(
{
group: "alert",
@@ -668,7 +667,7 @@ export default class NewEditProjectView extends Vue {
// remove any trailing '
const finalDerNumNoApostrophe = finalDerNum?.replace(/'/g, "");
const accountNum = Number(finalDerNumNoApostrophe || 0);
const extPubPri = nip06.extendedKeysFromSeedWords(
const extPubPri = extendedKeysFromSeedWords(
account?.mnemonic as string,
"",
accountNum,
@@ -676,7 +675,7 @@ export default class NewEditProjectView extends Vue {
const publicExtendedKey: string = extPubPri?.publicExtendedKey;
const privateExtendedKey = extPubPri?.privateExtendedKey;
const privateBytes: Uint8Array =
nip06.accountFromExtendedKey(privateExtendedKey).privateKey;
accountFromExtendedKey(privateExtendedKey).privateKey;
// No real content is necessary, we just want something signed,
// so we might as well use nostr libs for nostr functions.
// Besides: someday we may create real content that we can relay.
@@ -710,8 +709,7 @@ export default class NewEditProjectView extends Vue {
const endorserPartnerUrl = partnerServer + "/api/partner/link";
const timeSafariUrl = window.location.origin + "/claim/" + jwtId;
const content = this.fullClaim.name + " - see " + timeSafariUrl;
const publicKeyHex =
nip06.accountFromExtendedKey(publicExtendedKey).publicKey;
const publicKeyHex = accountFromExtendedKey(publicExtendedKey).publicKey;
const unsignedPayload: UnsignedEvent = {
// why doesn't "...signedPayload" work?
kind: signedPayload.kind,
@@ -760,7 +758,7 @@ export default class NewEditProjectView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error(`Error sending to ${serviceName}`, error);
logger.error(`Error sending to ${serviceName}`, error);
let errorMessage = `There was an error sending to ${serviceName}.`;
if (error.response?.data?.error?.message) {
errorMessage = error.response.data.error.message;
@@ -782,7 +780,7 @@ export default class NewEditProjectView extends Vue {
this.isHiddenSpinner = false;
if (this.numAccounts === 0) {
console.error("Error: there is no account.");
logger.error("Error: there is no account.");
} else {
this.saveProject();
}
@@ -810,7 +808,7 @@ export default class NewEditProjectView extends Vue {
}
public onCancelClick() {
(this.$router as Router).back();
this.$router.back();
}
public showNostrPartnerInfo() {

View File

@@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -25,16 +25,16 @@
<div />
<div v-if="loading">
<span class="text-xl">Creating...&nbsp;</span>
<fa
<font-awesome
icon="spinner"
class="fa-spin fa-spin-pulse"
color="green"
size="128"
></fa>
></font-awesome>
</div>
<div v-else>
<span class="text-xl">Created!</span>
<fa
<font-awesome
icon="burst"
class="fa-beat px-12"
color="green"
@@ -44,7 +44,7 @@
--fa-animation-iteration-count: 1;
--fa-beat-scale: 6;
"
></fa>
></font-awesome>
</div>
<div />
</div>
@@ -62,12 +62,13 @@ import QuickNav from "../components/QuickNav.vue";
@Component({ components: { QuickNav } })
export default class NewIdentifierView extends Vue {
loading = true;
$router!: Router;
async mounted() {
await generateSaveAndActivateIdentity();
this.loading = false;
setTimeout(() => {
(this.$router as Router).push({ name: "home" });
this.$router.push({ name: "home" });
}, 1000);
}
}

View File

@@ -13,7 +13,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -33,9 +33,9 @@
>
</h1>
<textarea
v-model="descriptionOfItem"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What is offered"
v-model="descriptionOfItem"
data-testId="itemDescription"
/>
<div class="flex flex-row justify-center">
@@ -49,19 +49,19 @@
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
<font-awesome icon="chevron-left" />
</div>
<input
v-model="amountInput"
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
data-testId="inputOfferAmount"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
<font-awesome icon="chevron-right" />
</div>
</div>
@@ -72,9 +72,9 @@
Conditions
</span>
<textarea
v-model="descriptionOfCondition"
class="w-full border border-slate-400 px-3 py-2 rounded-r"
placeholder="Prerequisites, other people to include, etc."
v-model="descriptionOfCondition"
/>
</div>
@@ -94,11 +94,11 @@
<div class="h-7 mt-4 flex">
<input
v-if="projectId && !offeredToRecipient"
v-model="offeredToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="offeredToProject"
/>
<fa
<font-awesome
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@@ -116,11 +116,11 @@
<div class="h-7 mt-4 flex">
<input
v-if="recipientDid && !offeredToProject"
v-model="offeredToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="offeredToRecipient"
/>
<fa
<font-awesome
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@@ -151,7 +151,7 @@
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
<font-awesome
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
@@ -176,24 +176,55 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { GenericCredWrapper, OfferVerifiableCredential } from "../interfaces";
import {
createAndSubmitOffer,
didInfo,
editAndSubmitOffer,
GenericCredWrapper,
getPlanFromCache,
hydrateOffer,
OfferVerifiableCredential,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
/**
* Offer Details View Component
* @author Matthew Raymer
*
* This component handles the creation and editing of offers within the platform.
* It supports both new offers and editing existing ones, with validation and
* submission handling.
*
* Features:
* - Offer amount and unit selection
* - Item description
* - Conditional requirements
* - Expiration date setting
* - Project or recipient targeting
* - Raw claim editing option
*
* Data Flow:
* 1. Component loads with optional previous offer data
* 2. Retrieves account settings and contact information
* 3. Populates form with existing or default values
* 4. Validates and submits offer to server
* 5. Redirects on success or shows error
*
* Security Features:
* - DID validation
* - JWT handling for edits
* - Server-side validation
* - Privacy controls for data sharing
*
* @see GiftedDialog for related gift creation
* @see ClaimAddRawView for raw claim editing
*/
@Component({
components: {
QuickNav,
@@ -201,40 +232,103 @@ import { retrieveAccountDids } from "../libs/util";
},
})
export default class OfferDetailsView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void;
/** Current route instance */
$route!: RouteLocationNormalizedLoaded;
/** Router instance for navigation */
$router!: Router;
/** Currently active DID */
activeDid = "";
/** API server endpoint */
apiServer = "";
/** Offer amount input field */
amountInput = "0";
/** Conditions for the offer */
descriptionOfCondition = "";
/** Description of offered item */
descriptionOfItem = "";
/** Path to redirect after completion */
destinationPathAfter = "";
/** Controls back button visibility */
hideBackButton = false;
/** Additional message to display */
message = "";
/** Flag for project assignment */
offeredToProject = false;
/** Flag for recipient assignment */
offeredToRecipient = false;
/** DID of offer creator */
offererDid: string | undefined;
/** Offer ID for editing */
offerId = "";
/** Previous offer data for editing */
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>;
/** Project ID if offer is for project */
projectId = "";
/** Project name display */
projectName = "a project";
/** Recipient DID if offer is for person */
recipientDid = "";
/** Recipient name display */
recipientName = "";
/** Advanced features visibility flag */
showGeneralAdvanced = false;
/** Unit type for offer amount */
unitCode = "HUR";
/** Expiration date input */
validThroughDateInput = "";
/** Utility library reference */
libsUtil = libsUtil;
/**
* Component lifecycle hook that initializes the offer form
*
* Workflow:
* 1. Extracts previous offer data if editing
* 2. Sets initial form values from route or previous offer
* 3. Loads account settings and contacts
* 4. Retrieves project information if needed
* 5. Sets offer assignment flags
*
* @throws Will not throw but shows notifications
* @emits Notifications on loading errors
*/
async mounted() {
try {
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
await this.loadPreviousOffer();
await this.initializeFormValues();
await this.loadAccountSettings();
await this.loadRecipientInfo();
await this.loadProjectInfo();
} catch (err: unknown) {
logger.error("Error in mounted:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error loading the offer details.",
},
5000,
);
}
}
/**
* Loads previous offer data if editing an existing offer
* @throws Will not throw but shows notifications
*/
private async loadPreviousOffer() {
try {
this.prevCredToEdit = (this.$route.query["prevCredToEdit"] as string)
? (JSON.parse(
(this.$route as Router).query["prevCredToEdit"],
this.$route.query["prevCredToEdit"] as string,
) as GenericCredWrapper<OfferVerifiableCredential>)
: undefined;
} catch (error) {
} catch (error: unknown) {
this.$notify(
{
group: "alert",
@@ -245,34 +339,38 @@ export default class OfferDetailsView extends Vue {
5000,
);
}
}
/**
* Initializes form values from route params or previous offer
*/
private async initializeFormValues() {
const prevAmount =
this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood;
this.amountInput =
(this.$route as Router).query["amountInput"] ||
(this.$route.query["amountInput"] as string) ||
(prevAmount ? String(prevAmount) : "") ||
this.amountInput;
this.unitCode = ((this.$route as Router).query["unitCode"] ||
this.unitCode = ((this.$route.query["unitCode"] as string) ||
this.prevCredToEdit?.claim?.includesObject?.unitCode ||
this.unitCode) as string;
this.descriptionOfCondition =
this.prevCredToEdit?.claim?.description || this.descriptionOfCondition;
this.descriptionOfItem =
(this.$route as Router).query["description"] ||
(this.$route.query["description"] as string) ||
this.prevCredToEdit?.claim?.itemOffered?.description ||
this.descriptionOfItem;
this.destinationPathAfter = (this.$route as Router).query[
"destinationPathAfter"
];
this.offererDid = ((this.$route as Router).query["offererDid"] ||
this.prevCredToEdit?.claim?.agent?.identifier ||
this.offererDid) as string;
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
// find any project ID
this.destinationPathAfter =
(this.$route.query["destinationPathAfter"] as string) || "";
this.hideBackButton =
(this.$route.query["hideBackButton"] as string) === "true";
this.message = (this.$route.query["message"] as string) || "";
// Set project info from previous offer or route
let project;
if (
this.prevCredToEdit?.claim?.itemOffered?.isPartOf?.["@type"] ===
@@ -280,57 +378,56 @@ export default class OfferDetailsView extends Vue {
) {
project = this.prevCredToEdit?.claim?.itemOffered?.isPartOf;
}
this.projectId = ((this.$route as Router).query["projectId"] ||
this.projectId = ((this.$route.query["projectId"] as string) ||
project?.identifier ||
this.projectId) as string;
this.projectName = ((this.$route as Router).query["projectName"] ||
this.projectName = ((this.$route.query["projectName"] as string) ||
project?.name ||
this.projectName) as string;
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
this.recipientDid = ((this.$route.query["recipientDid"] as string) ||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
this.recipientName =
((this.$route as Router).query["recipientName"] as string) || "";
this.recipientName = (this.$route.query["recipientName"] as string) || "";
this.validThroughDateInput =
this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput;
}
try {
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer ?? "";
this.activeDid = settings.activeDid ?? "";
this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false;
/**
* Loads account settings and updates component state
* @throws Will not throw but logs errors
*/
private async loadAccountSettings() {
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer ?? "";
this.activeDid = settings.activeDid ?? "";
this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false;
}
if (this.recipientDid && !this.recipientName) {
const allContacts = await db.contacts.toArray();
const allMyDids = await retrieveAccountDids();
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.offeredToProject = !!this.projectId;
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
5000,
/**
* Loads recipient information if recipient DID exists
*/
private async loadRecipientInfo() {
if (this.recipientDid && !this.recipientName) {
const allContacts = await db.contacts.toArray();
const allMyDids = await retrieveAccountDids();
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
// Set assignment flags
this.offeredToProject = !!this.projectId;
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
}
/**
* Loads project information if project ID exists
*/
private async loadProjectInfo() {
if (this.projectId && !this.projectName) {
// console.log("Getting project name from cache", this.projectId);
const project = await getPlanFromCache(
this.projectId,
this.axios,
@@ -343,16 +440,32 @@ export default class OfferDetailsView extends Vue {
}
}
/**
* Changes the unit type for the offer amount
*
* Cycles through available unit types in UNIT_SHORT.
* Updates display and internal state.
*/
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
/**
* Increments the offer amount by 1
*
* Handles string to number conversion and updates display.
*/
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
/**
* Decrements the offer amount by 1
*
* Prevents negative values and handles string to number conversion.
*/
decrement() {
this.amountInput = `${Math.max(
0,
@@ -360,6 +473,15 @@ export default class OfferDetailsView extends Vue {
)}`;
}
/**
* Handles cancellation of offer creation/editing
*
* Workflow:
* 1. Checks for destination path
* 2. Navigates to destination or previous page
*
* @emits Router navigation
*/
cancel() {
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
@@ -368,10 +490,28 @@ export default class OfferDetailsView extends Vue {
}
}
/**
* Handles back button navigation
*
* @emits Router navigation to previous page
*/
cancelBack() {
(this.$router as Router).back();
}
/**
* Validates and initiates offer submission
*
* Workflow:
* 1. Validates active DID exists
* 2. Checks for negative amounts
* 3. Ensures description or amount exists
* 4. Shows processing notification
* 5. Calls recordOffer for submission
*
* @throws Will not throw but shows notifications
* @emits Notifications for validation errors or processing
*/
async confirm() {
if (!this.activeDid) {
this.$notify(
@@ -426,6 +566,15 @@ export default class OfferDetailsView extends Vue {
await this.recordOffer();
}
/**
* Notifies user about project assignment restrictions
*
* Shows appropriate error message based on:
* - Missing project ID
* - Conflict with recipient assignment
*
* @emits Notification with error message
*/
notifyUserOfProject() {
if (!this.projectId) {
this.$notify(
@@ -451,6 +600,15 @@ export default class OfferDetailsView extends Vue {
}
}
/**
* Notifies user about recipient assignment restrictions
*
* Shows appropriate error message based on:
* - Missing recipient DID
* - Conflict with project assignment
*
* @emits Notification with error message
*/
notifyUserOfRecipient() {
if (!this.recipientDid) {
this.$notify(
@@ -477,11 +635,18 @@ export default class OfferDetailsView extends Vue {
}
/**
* Records the offer to the server
*
* @param offererDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
* Workflow:
* 1. Determines if editing existing or creating new
* 2. Prepares offer data with assignments
* 3. Submits to server via appropriate method
* 4. Handles success/error responses
* 5. Navigates on success
*
* @throws Will not throw but shows notifications
* @emits Notifications for success/failure
* @emits Router navigation on success
*/
public async recordOffer() {
try {
@@ -522,7 +687,7 @@ export default class OfferDetailsView extends Vue {
if (result.type === "error" || this.isCreationError(result.response)) {
const errorMessage = this.getCreationErrorMessage(result);
console.error("Error with offer creation result:", result);
logger.error("Error with offer creation result:", result);
this.$notify(
{
group: "alert",
@@ -551,7 +716,7 @@ export default class OfferDetailsView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with offer recordation caught:", error);
logger.error("Error with offer recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
@@ -568,6 +733,17 @@ export default class OfferDetailsView extends Vue {
}
}
/**
* Constructs offer parameters for raw editing
*
* Creates a JSON string containing:
* - Offer details
* - Assignments
* - Conditions
* - Expiration
*
* @returns JSON string of offer parameters
*/
constructOfferParam() {
const recipientDid = this.offeredToRecipient
? this.recipientDid
@@ -589,11 +765,11 @@ export default class OfferDetailsView extends Vue {
return claimStr;
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
* Checks if server response indicates an error
*
* @param result Response data from server
* @returns true if response indicates error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isCreationError(result: any) {
@@ -601,8 +777,10 @@ export default class OfferDetailsView extends Vue {
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
* Extracts error message from server response
*
* @param result Server response object
* @returns Best available error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getCreationErrorMessage(result: any) {
@@ -613,6 +791,13 @@ export default class OfferDetailsView extends Vue {
);
}
/**
* Shows privacy information dialog
*
* Displays standard privacy message about data sharing.
*
* @emits Notification with privacy message
*/
explainData() {
this.$notify(
{

View File

@@ -10,7 +10,7 @@
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8">
<fa icon="spinner" class="fa-spin-pulse" />
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else-if="attendingMeeting">
@@ -22,11 +22,11 @@
<div class="flex justify-between items-center">
<h2 class="text-xl font-medium">{{ attendingMeeting.name }}</h2>
<button
@click.stop="leaveMeeting"
class="text-red-600 hover:text-red-700 p-2"
title="Leave Meeting"
@click.stop="leaveMeeting"
>
<fa icon="right-from-bracket" />
<font-awesome icon="right-from-bracket" />
</button>
</div>
</div>
@@ -65,14 +65,14 @@
/>
<div class="flex justify-end space-x-4">
<button
@click="cancelPasswordDialog"
class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
@click="cancelPasswordDialog"
>
Cancel
</button>
<button
@click="submitPassword"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
@click="submitPassword"
>
Submit
</button>
@@ -120,6 +120,7 @@ export default class OnboardMeetingListView extends Vue {
},
timeout?: number,
) => void;
$router!: Router;
activeDid = "";
apiServer = "";
@@ -257,7 +258,7 @@ export default class OnboardMeetingListView extends Vue {
if (postResult.data && postResult.data.success) {
// Navigate to members view with password and groupId
(this.$router as Router).push({
this.$router.push({
name: "onboard-meeting-members",
params: {
groupId: this.selectedMeeting.groupId.toString(),

View File

@@ -10,10 +10,10 @@
<!-- Loading Animation -->
<div
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
v-if="isLoading"
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
<font-awesome icon="spinner" class="fa-spin-pulse"></font-awesome>
</div>
<!-- Error State -->
@@ -39,7 +39,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocation } from "vue-router";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
@@ -69,17 +69,17 @@ export default class OnboardMeetingMembersView extends Vue {
firstName = "";
isRegistered = false;
isLoading = true;
$route!: RouteLocationNormalizedLoaded;
$router!: Router;
$refs!: {
userNameDialog: InstanceType<typeof UserNameDialog>;
};
userNameDialog!: InstanceType<typeof UserNameDialog>;
get groupId(): string {
return (this.$route as RouteLocation).params.groupId as string;
return (this.$route.params.groupId as string) || "";
}
get password(): string {
return (this.$route as RouteLocation).query.password as string;
return (this.$route.query.password as string) || "";
}
async created() {

View File

@@ -17,24 +17,24 @@
<div class="flex items-center">
<h2 class="text-2xl">Current Meeting</h2>
<button
@click="startEditing"
class="mb-4 text-blue-600 hover:text-blue-800 transition-colors duration-200 ml-2"
title="Edit Meeting"
@click="startEditing"
>
<fa icon="pen" class="fa-fw" />
<font-awesome icon="pen" class="fa-fw" />
<span class="sr-only">{{
isInCreateMode() ? "Create Meeting" : "Edit Meeting"
}}</span>
</button>
</div>
<button
@click="confirmDelete"
class="text-red-600 hover:text-red-800 transition-colors duration-200"
:disabled="isDeleting"
:class="{ 'opacity-50 cursor-not-allowed': isDeleting }"
title="Delete Meeting"
@click="confirmDelete"
>
<fa icon="trash-can" class="fa-fw" />
<font-awesome icon="trash-can" class="fa-fw" />
<span class="sr-only">{{
isDeleting ? "Deleting..." : "Delete Meeting"
}}</span>
@@ -72,14 +72,14 @@
</p>
<div class="flex justify-between space-x-4">
<button
@click="showDeleteConfirm = false"
class="px-4 py-2 bg-slate-500 text-white rounded hover:bg-slate-700"
@click="showDeleteConfirm = false"
>
Cancel
</button>
<button
@click="deleteMeeting"
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
@click="deleteMeeting"
>
Delete
</button>
@@ -99,10 +99,13 @@
<h2 class="text-2xl mb-4">
{{ isInCreateMode() ? "Create New Meeting" : "Edit Meeting" }}
</h2>
<!-- This is my first form. Not sure if I like it; will see if the browser benefits extend to the native app. -->
<!--
This is my first form. Not sure if I like it; will see if the browser benefits extend to
the native app.
-->
<form
@submit.prevent="isInCreateMode() ? createMeeting() : updateMeeting()"
class="space-y-4"
@submit.prevent="isInCreateMode() ? createMeeting() : updateMeeting()"
>
<div>
<label
@@ -182,8 +185,8 @@
<button
v-if="isInEditOrCreateMode()"
type="button"
@click="cancelEditing"
class="w-full bg-slate-500 text-white px-4 py-2 rounded-md hover:bg-slate-600"
@click="cancelEditing"
>
Cancel
</button>
@@ -204,20 +207,21 @@
class="inline-block text-blue-600"
target="_blank"
>
&bull; Open shortcut page for members <fa icon="external-link" />
&bull; Open shortcut page for members
<font-awesome icon="external-link" />
</router-link>
<MembersList
:password="currentMeeting.password || ''"
:show-organizer-tools="true"
@error="handleMembersError"
class="mt-4"
@error="handleMembersError"
/>
</div>
<div v-else-if="isLoading">
<div class="flex justify-center items-center h-full">
<fa icon="spinner" class="fa-spin-pulse" />
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
</div>
</section>
@@ -235,7 +239,7 @@ import {
serverMessageForUser,
} 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
@@ -511,7 +515,7 @@ export default class OnboardMeetingView extends Vue {
3000,
);
} catch (error) {
console.error("Error deleting meeting:", error);
logger.error("Error deleting meeting:", error);
this.$notify(
{
group: "alert",
@@ -537,7 +541,7 @@ export default class OnboardMeetingView extends Vue {
password: this.currentMeeting.password || "",
};
} else {
console.error(
logger.error(
"There is no current meeting to edit. We should never get here.",
);
}

View File

@@ -10,10 +10,10 @@
<h1 class="text-center text-lg font-light relative px-7">
<!-- Back -->
<button
@click="$router.go(-1)"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Project Idea
</h1>
@@ -21,11 +21,11 @@
{{ name }}
<button
v-if="activeDid === issuer || activeDid === agentDid"
@click="onEditClick()"
title="Edit"
data-testId="editClaimButton"
@click="onEditClick()"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</h2>
</div>
@@ -37,10 +37,10 @@
<div class="pb-4 flex gap-4">
<div class="pt-1">
<ProjectIcon
:entityId="projectId"
:iconSize="64"
:imageUrl="imageUrl"
:linkToFull="true"
:entity-id="projectId"
:icon-size="64"
:image-url="imageUrl"
:link-to-full="true"
class="block border border-slate-300 rounded-md max-h-16 max-w-16"
/>
</div>
@@ -48,7 +48,10 @@
<div class="overflow-hidden">
<div class="text-sm mb-3">
<div class="truncate">
<fa icon="user" class="fa-fw text-slate-400"></fa>
<font-awesome
icon="user"
class="fa-fw text-slate-400"
></font-awesome>
{{ issuerInfoObject?.displayName }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
<a
@@ -56,11 +59,14 @@
target="_blank"
class="text-blue-500"
>
<fa icon="arrow-up-right-from-square" class="fa-fw" />
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</span>
<span v-else-if="serverUtil.isHiddenDid(issuer)">
<fa
<font-awesome
icon="info-circle"
class="fa-fw text-blue-500 cursor-pointer"
@click="openHiddenDidDialog()"
@@ -68,35 +74,50 @@
</span>
</div>
<div v-if="startTime">
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
<font-awesome
icon="calendar"
class="fa-fw text-slate-400"
></font-awesome>
Starts {{ startTime }}
</div>
<div v-if="endTime">
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
<font-awesome
icon="calendar"
class="fa-fw text-slate-400"
></font-awesome>
Ends {{ endTime }}
</div>
<div v-if="latitude || longitude">
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
<font-awesome
icon="location-dot"
class="fa-fw text-slate-400"
></font-awesome>
<a
:href="getOpenStreetMapUrl()"
target="_blank"
class="underline text-blue-500"
>Map View
<fa
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw text-blue-500"
/>
</a>
</div>
<div v-if="url">
<fa icon="globe" class="fa-fw text-slate-400"></fa>
<font-awesome
icon="globe"
class="fa-fw text-slate-400"
></font-awesome>
<a
:href="addScheme(url)"
target="_blank"
class="underline text-blue-500"
>
{{ domainForWebsite(this.url) }}
<fa icon="arrow-up-right-from-square" class="fa-fw" />
{{ domainForWebsite(url) }}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</div>
</div>
@@ -108,23 +129,23 @@
{{ truncatedDesc }}
<a
v-if="description.length >= truncateLength"
@click="expandText"
class="uppercase text-xs font-semibold text-slate-700"
@click="expandText"
>... Read More</a
>
</div>
<div v-else>
{{ description }}
<a
@click="collapseText"
class="uppercase text-xs font-semibold text-slate-700"
@click="collapseText"
>- Read Less</a
>
</div>
</div>
<a @click="onClickLoadClaim(projectId)" class="cursor-pointer">
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
<a class="cursor-pointer" @click="onClickLoadClaim(projectId)">
<font-awesome icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a>
</div>
</div>
@@ -138,12 +159,15 @@
<h3 class="text-sm uppercase font-semibold mt-3">
Projects That Contribute To This
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<!--
centering because long, wrapped project names didn't left align with blank
or "text-left"
-->
<div class="text-center">
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
<button
@click="onClickLoadProject(plan.handleId)"
class="text-blue-500"
@click="onClickLoadProject(plan.handleId)"
>
{{ plan.name }}
</button>
@@ -160,11 +184,14 @@
<h3 class="text-sm uppercase font-semibold mb-3">
Projects Getting Contributions From This
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<!--
centering because long, wrapped project names didn't left align with blank
or "text-left"
-->
<div class="text-center">
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
@click="onClickLoadProject(fulfilledByThis.handleId)"
>
{{ fulfilledByThis.name }}
</button>
@@ -181,7 +208,10 @@
class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-x-3 gap-y-5 text-center mb-5 mt-2"
>
<li @click="openGiftDialogToProject({ name: 'you', did: activeDid })">
<fa icon="hand" class="fa-fw text-blue-500 text-5xl cursor-pointer" />
<font-awesome
icon="hand"
class="fa-fw text-blue-500 text-5xl cursor-pointer"
/>
<h3
class="mt-5 text-xs text-blue-500 font-medium text-ellipsis whitespace-nowrap overflow-hidden cursor-pointer"
>
@@ -206,7 +236,7 @@
>
<EntityIcon
:contact="contact"
:iconSize="64"
:icon-size="64"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/>
<h3
@@ -218,14 +248,14 @@
<li>
<span
v-if="allContacts.length >= 5"
@click="onClickAllContactsGifting()"
class="flex align-bottom text-xs text-blue-500 mt-12 cursor-pointer"
@click="onClickAllContactsGifting()"
>
... or someone else...
</span>
</li>
</ul>
<GiftedDialog ref="giveDialogToThis" :toProjectId="this.projectId" />
<GiftedDialog ref="giveDialogToThis" :to-project-id="projectId" />
</div>
<!-- Offers & Gifts to & from this -->
@@ -236,8 +266,8 @@
<div class="text-center">
<button
data-testId="offerButton"
@click="openOfferDialog()"
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md"
@click="openOfferDialog()"
>
Offer to this (maybe with conditions)...
</button>
@@ -245,15 +275,15 @@
</div>
<OfferDialog
ref="customOfferDialog"
:projectId="this.projectId"
:projectName="this.name"
:project-id="projectId"
:project-name="name"
/>
<h3 class="text-lg font-bold mb-3 mt-4">Offered To This Idea</h3>
<div v-if="offersToThis.length === 0">
(None yet. Wanna
<span @click="openOfferDialog()" class="cursor-pointer text-blue-500"
<span class="cursor-pointer text-blue-500" @click="openOfferDialog()"
>offer something... especially if others join you</span
>?)
</div>
@@ -261,12 +291,15 @@
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="offer in offersToThis"
:key="offer.id"
:key="offer.jwtId"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
<fa icon="user" class="fa-fw text-slate-400"></fa>
<font-awesome
icon="user"
class="fa-fw text-slate-400"
></font-awesome>
{{
serverUtil.didInfo(
offer.offeredByDid,
@@ -277,28 +310,31 @@
}}
</span>
<span v-if="offer.amount" class="whitespace-nowrap">
<fa
<font-awesome
:icon="libsUtil.iconForUnitCode(offer.unit)"
class="fa-fw text-slate-400"
/>{{ offer.amount }}
</span>
</div>
<div v-if="offer.objectDescription" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
<font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ offer.objectDescription }}
</div>
<div class="flex justify-between">
<a
@click="onClickLoadClaim(offer.jwtId as string)"
class="cursor-pointer"
@click="onClickLoadClaim(offer.jwtId as string)"
>
<fa icon="file-lines" class="pl-2 pt-1 text-blue-500" />
<font-awesome
icon="file-lines"
class="pl-2 pt-1 text-blue-500"
/>
</a>
<a
v-if="checkIsFulfillable(offer)"
@click="onClickFulfillGiveToOffer(offer)"
>
<fa
<font-awesome
icon="hand-holding-heart"
class="text-blue-500 cursor-pointer"
/>
@@ -317,8 +353,8 @@
<div v-if="activeDid && isRegistered">
<div class="text-center">
<button
@click="openGiftDialogToProject()"
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1rounded-md"
@click="openGiftDialogToProject()"
>
Given To This...
</button>
@@ -342,8 +378,8 @@
<span class="font-semibold mr-2 shrink-0">Totals</span>
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
<a
@click="totalsExpanded = !totalsExpanded"
class="cursor-pointer text-blue-500"
@click="totalsExpanded = !totalsExpanded"
>
<!-- just show the hours, or alternatively whatever is first -->
<span v-if="givenTotalHours() > 0">
@@ -469,17 +505,14 @@
<div v-if="activeDid && isRegistered">
<div class="text-center">
<button
@click="openGiftDialogFromProject()"
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md"
@click="openGiftDialogFromProject()"
>
Given By This...
</button>
</div>
</div>
<GiftedDialog
ref="giveDialogFromThis"
:fromProjectId="this.projectId"
/>
<GiftedDialog ref="giveDialogFromThis" :from-project-id="projectId" />
<h3 class="text-lg font-bold mb-3 mt-4">
Benefitted From This Project
@@ -490,7 +523,7 @@
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="give in givesProvidedByThis"
:key="give.id"
:key="give.jwtId"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
@@ -505,23 +538,26 @@
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
<font-awesome
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
<font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<div class="flex justify-between">
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
<font-awesome
icon="file-lines"
class="text-blue-500 cursor-pointer"
/>
</a>
<a
@@ -531,13 +567,19 @@
"
@click="deepCheckConfirmable(give)"
>
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
<font-awesome
icon="circle-check"
class="text-blue-500 cursor-pointer"
/>
</a>
<a v-else-if="checkingConfirmationForJwtId === give.jwtId">
<fa icon="spinner" class="fa-spin-pulse" />
<font-awesome icon="spinner" class="fa-spin-pulse" />
</a>
<a v-else @click="shallowNotifyWhyCannotConfirm(give)">
<fa icon="circle-check" class="text-slate-500 cursor-pointer" />
<font-awesome
icon="circle-check"
class="text-slate-500 cursor-pointer"
/>
</a>
</div>
<div v-if="give.fullClaim.image" class="flex justify-center">
@@ -561,7 +603,15 @@
import { AxiosError } from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import {
GenericVerifiableCredential,
GenericCredWrapper,
GiveSummaryRecord,
GiveVerifiableCredential,
OfferSummaryRecord,
OfferVerifiableCredential,
PlanSummaryRecord,
} from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import OfferDialog from "../components/OfferDialog.vue";
import TopMessage from "../components/TopMessage.vue";
@@ -576,18 +626,42 @@ import {
} from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as libsUtil from "../libs/util";
import {
GenericCredWrapper,
GiveSummaryRecord,
GiveVerifiableCredential,
OfferSummaryRecord,
OfferVerifiableCredential,
PlanSummaryRecord,
} from "../libs/endorserServer";
import * as serverUtil from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger";
/**
* Project View Component
* @author Matthew Raymer
*
* This component displays and manages detailed project information. It handles:
* - Project loading and display from URL-encoded project handles
* - Project metadata (name, description, dates, location)
* - Issuer information and verification
* - Project contributions and fulfillments
* - Offers and gifts tracking
* - Contact interactions
*
* Data Flow:
* 1. Component loads with project ID from route
* 2. Fetches project data, contacts, and account settings
* 3. Loads related data (offers, gifts, fulfillments)
* 4. Updates UI with paginated results
*
* Security Features:
* - DID visibility controls
* - JWT validation for imports
* - Permission checks for actions
*
* State Management:
* - Maintains separate loading states for different data types
* - Handles pagination limits
* - Tracks confirmation states
*
* @see GiftedDialog for gift creation
* @see OfferDialog for offer creation
* @see HiddenDidDialog for DID privacy explanations
*/
@Component({
components: {
EntityIcon,
@@ -600,52 +674,103 @@ import HiddenDidDialog from "../components/HiddenDidDialog.vue";
},
})
export default class ProjectViewView extends Vue {
/** Notification function injected by Vue */
$notify!: (notification: NotificationIface, timeout?: number) => void;
/** Router instance for navigation */
$router!: Router;
// Account and Settings State
/** Currently active DID */
activeDid = "";
/** Project agent DID */
agentDid = "";
/** DIDs that can see the agent DID */
agentDidVisibleToDids: Array<string> = [];
/** All DIDs associated with current account */
allMyDids: Array<string> = [];
/** All known contacts */
allContacts: Array<Contact> = [];
/** API server endpoint */
apiServer = "";
checkingConfirmationForJwtId = "";
/** Registration status of current user */
isRegistered = false;
// Project Data
/** Project description */
description = "";
/** Project end time */
endTime = "";
/** Text expansion state */
expanded = false;
/** Project fulfilled by this project */
fulfilledByThis: PlanSummaryRecord | null = null;
/** Projects fulfilling this project */
fulfillersToThis: Array<PlanSummaryRecord> = [];
/** Flag for fulfiller pagination */
fulfillersToHitLimit = false;
/** Gifts to this project */
givesToThis: Array<GiveSummaryRecord> = [];
givesHitLimit = false;
givesProvidedByThis: Array<GiveSummaryRecord> = [];
givesProvidedByHitLimit = false;
givesTotalsByUnit: Array<{ unit: string; amount: number }> = [];
imageUrl = "";
isRegistered = false;
/** Project issuer DID */
issuer = "";
/** Cached issuer information */
issuerInfoObject: {
known: boolean;
displayName: string;
profileImageUrl?: string;
} | null = null;
/** DIDs that can see issuer information */
issuerVisibleToDids: Array<string> = [];
/** Project location data */
latitude = 0;
loadingTotals = false;
longitude = 0;
/** Project name */
name = "";
offersToThis: Array<OfferSummaryRecord> = [];
offersHitLimit = false;
projectId = ""; // handle ID
recentlyCheckedAndUnconfirmableJwts: string[] = [];
/** Project ID (handle) */
projectId = "";
/** Project start time */
startTime = "";
totalsExpanded = false;
truncatedDesc = "";
truncateLength = 40;
/** Project URL */
url = "";
// Interaction Data
/** Gifts to this project */
offersToThis: Array<OfferSummaryRecord> = [];
/** Flag for offers pagination */
offersHitLimit = false;
// UI State
/** JWT being checked for confirmation */
checkingConfirmationForJwtId = "";
/** Recently checked unconfirmable JWTs */
recentlyCheckedAndUnconfirmableJwts: string[] = [];
totalsExpanded = false;
truncatedDesc = "";
/** Truncation length */
truncateLength = 40;
// Utility References
libsUtil = libsUtil;
serverUtil = serverUtil;
/**
* Component lifecycle hook that initializes the project view
*
* Workflow:
* 1. Loads account settings and contacts
* 2. Retrieves all account DIDs
* 3. Extracts project ID from URL
* 4. Initializes project data loading
*
* @throws Logs errors but continues loading
* @emits Notification on profile loading errors
*/
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
@@ -680,12 +805,14 @@ export default class ProjectViewView extends Vue {
this.loadTotals();
}
onEditClick() {
const route = {
/**
* Navigates to project edit view with current project ID
*/
onEditClick(): void {
this.$router.push({
name: "new-edit-project",
query: { projectId: this.projectId },
};
(this.$router as Router).push(route);
});
}
// Isn't there a better way to make this available to the template?
@@ -743,7 +870,7 @@ export default class ProjectViewView extends Vue {
this.url = resp.data.claim?.url || "";
} else {
// actually, axios throws an error on 404 so we probably never get here
console.error("Error getting project:", resp);
logger.error("Error getting project:", resp);
this.$notify(
{
group: "alert",
@@ -755,7 +882,7 @@ export default class ProjectViewView extends Vue {
);
}
} catch (error: unknown) {
console.error("Error retrieving project:", error);
logger.error("Error retrieving project:", error);
this.$notify(
{
group: "alert",
@@ -783,6 +910,15 @@ export default class ProjectViewView extends Vue {
this.loadPlanFulfilledBy();
}
/**
* Loads gifts made to this project
*
* Handles pagination and updates component state with results.
* Uses beforeId for pagination based on last loaded gift.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadGives() {
const givesUrl =
this.apiServer +
@@ -823,13 +959,22 @@ export default class ProjectViewView extends Vue {
},
5000,
);
console.error(
logger.error(
"Something went wrong retrieving more gives to this project:",
serverError.message,
);
}
}
/**
* Loads gifts provided by this project
*
* Similar to loadGives but for outgoing gifts.
* Maintains separate pagination state.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadGivesProvidedBy() {
const providedByUrl =
this.apiServer +
@@ -873,13 +1018,22 @@ export default class ProjectViewView extends Vue {
},
5000,
);
console.error(
logger.error(
"Something went wrong retrieving gives that were provided by this project:",
serverError.message,
);
}
}
/**
* Loads offers made to this project
*
* Handles pagination and filtering of valid offers.
* Updates component state with results.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadOffers() {
const offersUrl =
this.apiServer +
@@ -920,13 +1074,21 @@ export default class ProjectViewView extends Vue {
},
5000,
);
console.error(
logger.error(
"Something went wrong retrieving more offers to this project:",
serverError.message,
);
}
}
/**
* Loads projects that fulfill this project
*
* Manages pagination state and updates component with results.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadPlanFulfillersTo() {
const fulfillsUrl =
this.apiServer +
@@ -968,13 +1130,21 @@ export default class ProjectViewView extends Vue {
},
5000,
);
console.error(
logger.error(
"Something went wrong retrieving more plans that fulfill this project:",
serverError.message,
);
}
}
/**
* Loads project that this project fulfills
*
* Updates fulfilledByThis state with result.
*
* @throws Logs errors and notifies user
* @emits Notification on loading errors
*/
async loadPlanFulfilledBy() {
const fulfilledByUrl =
this.apiServer +
@@ -1007,7 +1177,7 @@ export default class ProjectViewView extends Vue {
},
5000,
);
console.error(
logger.error(
"Error retrieving plans fulfilled by this project:",
serverError.message,
);
@@ -1022,7 +1192,7 @@ export default class ProjectViewView extends Vue {
const route = {
path: "/project/" + encodeURIComponent(projectId),
};
(this.$router as Router).push(route);
this.$router.push(route);
this.loadProject(projectId, this.activeDid);
}
@@ -1069,14 +1239,14 @@ export default class ProjectViewView extends Vue {
projectId: this.projectId,
},
};
(this.$router as Router).push(route);
this.$router.push(route);
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
(this.$router as Router).push(route);
this.$router.push(route);
}
checkIsFulfillable(offer: OfferSummaryRecord) {
@@ -1225,7 +1395,7 @@ export default class ProjectViewView extends Vue {
),
),
);
const confirmationClaim: serverUtil.GenericVerifiableCredential = {
const confirmationClaim: GenericVerifiableCredential = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
@@ -1251,7 +1421,7 @@ export default class ProjectViewView extends Vue {
give.jwtId,
];
} else {
console.error("Got error submitting the confirmation:", result);
logger.error("Got error submitting the confirmation:", result);
const message =
(result.error?.error as string) ||
"There was a problem submitting the confirmation.";
@@ -1307,7 +1477,7 @@ export default class ProjectViewView extends Vue {
);
}
} catch (error) {
console.error("Error loading totals:", error);
logger.error("Error loading totals:", error);
this.$notify(
{
group: "alert",

View File

@@ -16,6 +16,7 @@
<li>
<a
href="#"
:class="computedOfferTabClassNames()"
@click="
offers = [];
projects = [];
@@ -23,7 +24,6 @@
showProjects = false;
loadOffers();
"
v-bind:class="computedOfferTabClassNames()"
>
Offers
</a>
@@ -31,6 +31,7 @@
<li>
<a
href="#"
:class="computedProjectTabClassNames()"
@click="
offers = [];
projects = [];
@@ -38,7 +39,6 @@
showProjects = true;
loadProjects();
"
v-bind:class="computedProjectTabClassNames()"
>
Projects
</a>
@@ -57,7 +57,7 @@
<button
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
>
<fa icon="magnifying-glass" class="fa-fw"></fa>
<font-awesome icon="magnifying-glass" class="fa-fw"></font-awesome>
</button>
</div>
-->
@@ -68,15 +68,15 @@
class="fixed right-6 top-24 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"
@click="onClickNewProject()"
>
<fa icon="plus" class="fa-fw"></fa>
<font-awesome icon="plus" class="fa-fw"></font-awesome>
</button>
<!-- Loading Animation -->
<div
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
v-if="isLoading"
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
<font-awesome icon="spinner" class="fa-spin-pulse"></font-awesome>
</div>
<!-- Offer Results List -->
@@ -90,22 +90,22 @@
</div>
<ul id="listOffers" class="border-t border-slate-300">
<li
class="border-b border-slate-300"
v-for="offer in offers"
:key="offer.handleId"
class="border-b border-slate-300"
>
<div class="block py-4 flex gap-4">
<div v-if="offer.fulfillsPlanHandleId" class="flex-none">
<ProjectIcon
:entityId="offer.fulfillsPlanHandleId"
:iconSize="48"
:entity-id="offer.fulfillsPlanHandleId"
:icon-size="48"
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
<div v-if="offer.recipientDid" class="flex-none w-12">
<EntityIcon
:entityId="offer.recipientDid"
:iconSize="48"
:entity-id="offer.recipientDid"
:icon-size="48"
class="inline-block align-middle border border-slate-300 rounded-md"
/>
</div>
@@ -130,17 +130,20 @@
<span class="text-sm">
<span v-if="offer.amount">
<fa
<font-awesome
:icon="libsUtil.iconForUnitCode(offer.unit)"
class="fa-fw text-slate-400"
/>
<span v-if="offer.amountGiven >= offer.amount">
<fa icon="check-circle" class="fa-fw text-green-500" />
<font-awesome
icon="check-circle"
class="fa-fw text-green-500"
/>
All {{ offer.amount }} given
</span>
<span v-else>
<fa
<font-awesome
icon="triangle-exclamation"
class="fa-fw text-yellow-500"
/>
@@ -156,13 +159,14 @@
>
<!--
There's no need for a green icon:
it's unnecessary if there's already a green, and confusing if there's a yellow.
it's unnecessary if there's already a green, and confusing if there's a
yellow.
-->
all
</span>
<span v-else>
<!-- only show icon if there's not already a warning -->
<fa
<font-awesome
v-if="offer.amountGiven >= offer.amount"
icon="triangle-exclamation"
class="fa-fw text-yellow-300"
@@ -176,13 +180,16 @@
<span v-else>
<!-- Non-amount offer -->
<span v-if="offer.nonAmountGivenConfirmed">
<fa icon="check-circle" class="fa-fw text-green-500" />
<font-awesome
icon="check-circle"
class="fa-fw text-green-500"
/>
{{ offer.nonAmountGivenConfirmed }}
{{ offer.nonAmountGivenConfirmed == 1 ? "give" : "gives" }}
are confirmed.
</span>
<span v-else>
<fa
<font-awesome
icon="triangle-exclamation"
class="fa-fw text-yellow-500"
/>
@@ -191,10 +198,10 @@
</span>
<a @click="onClickLoadClaim(offer.jwtId)">
<fa
<font-awesome
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
></fa>
></font-awesome>
</a>
</span>
</div>
@@ -209,7 +216,7 @@
You have not announced any projects.
<div v-if="isRegistered">
Hit the big
<fa
<font-awesome
icon="plus"
class="bg-green-600 text-white px-1.5 py-1 rounded-full"
/>
@@ -217,8 +224,8 @@
</div>
<div v-else>
<button
@click="showNameThenIdDialog()"
class="text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
@click="showNameThenIdDialog()"
>
Get someone to onboard you.
</button>
@@ -227,19 +234,19 @@
</div>
<ul id="listProjects" class="border-t border-slate-300">
<li
class="border-b border-slate-300"
v-for="project in projects"
:key="project.handleId"
class="border-b border-slate-300"
>
<a
@click="onClickLoadProject(project.handleId)"
class="block py-4 flex gap-4"
@click="onClickLoadProject(project.handleId)"
>
<div class="flex-none">
<ProjectIcon
:entityId="project.handleId"
:iconSize="48"
:imageUrl="project.image"
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="inline-block align-middle border border-slate-300 rounded-md max-h-12 max-w-12"
/>
</div>
@@ -281,7 +288,7 @@ import {
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { OnboardPage } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: {
EntityIcon,
@@ -295,7 +302,9 @@ import { OnboardPage } from "../libs/util";
})
export default class ProjectsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
errNote(message) {
$router!: Router;
errNote(message: string) {
this.$notify(
{ group: "alert", type: "danger", title: "Error", text: message },
5000,
@@ -337,13 +346,13 @@ export default class ProjectsView extends Vue {
}
if (this.allMyDids.length === 0) {
console.error("No accounts found.");
logger.error("No accounts found.");
this.errNote("You need an identifier to load your projects.");
} else {
await this.loadProjects();
}
} catch (err) {
console.error("Error initializing:", err);
logger.error("Error initializing:", err);
this.errNote("Something went wrong loading your projects.");
}
}
@@ -372,7 +381,7 @@ export default class ProjectsView extends Vue {
});
}
} else {
console.error(
logger.error(
"Bad server response & data for plans:",
resp.status,
resp.data,
@@ -381,7 +390,7 @@ export default class ProjectsView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Got error loading plans:", error.message || error);
logger.error("Got error loading plans:", error.message || error);
this.errNote("Got an error loading projects.");
} finally {
this.isLoading = false;
@@ -417,7 +426,7 @@ export default class ProjectsView extends Vue {
const route = {
path: "/project/" + encodeURIComponent(id),
};
(this.$router as Router).push(route);
this.$router.push(route);
}
/**
@@ -427,14 +436,14 @@ export default class ProjectsView extends Vue {
const route = {
name: "new-edit-project",
};
(this.$router as Router).push(route);
this.$router.push(route);
}
onClickLoadClaim(jwtId: string) {
const route = {
path: "/claim/" + encodeURIComponent(jwtId),
};
(this.$router as Router).push(route);
this.$router.push(route);
}
/**
@@ -465,7 +474,7 @@ export default class ProjectsView extends Vue {
this.offers = this.offers.concat([offer]);
}
} else {
console.error(
logger.error(
"Bad server response & data for offers:",
resp.status,
resp.data,
@@ -482,7 +491,7 @@ export default class ProjectsView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Got error loading offers:", error.message || error);
logger.error("Got error loading offers:", error.message || error);
this.$notify(
{
group: "alert",
@@ -537,10 +546,10 @@ export default class ProjectsView extends Vue {
text: "If so, we'll use those with QR codes to share.",
onCancel: async () => {},
onNo: async () => {
(this.$router as Router).push({ name: "share-my-contact-info" });
this.$router.push({ name: "share-my-contact-info" });
},
onYes: async () => {
(this.$router as Router).push({ name: "contact-qr" });
this.$router.push({ name: "contact-qr" });
},
noText: "we will share another way",
yesText: "we are nearby with cameras",

View File

@@ -10,7 +10,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -22,17 +22,17 @@
<div>
<h2 class="text-2xl m-2">You're Here</h2>
<div class="m-2 flex">
<input type="checkbox" v-model="attended" class="h-6 w-6" />
<input v-model="attended" type="checkbox" class="h-6 w-6" />
<span class="pb-2 pl-2 pr-2">Attended</span>
</div>
<div class="m-2 flex">
<input type="checkbox" v-model="gaveTime" class="h-6 w-6" />
<input v-model="gaveTime" type="checkbox" class="h-6 w-6" />
<span class="pb-2 pl-2 pr-2">Spent Time</span>
<span v-if="gaveTime">
<input
v-model="hoursStr"
type="text"
placeholder="How much time"
v-model="hoursStr"
size="1"
class="border border-slate-400 h-6 px-2"
/>
@@ -48,8 +48,8 @@
class="flex justify-center mt-4"
>
<button
@click="record()"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md w-56"
@click="record()"
>
Sign & Send
</button>
@@ -81,7 +81,7 @@ import {
createAndSubmitGive,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: {
QuickNav,
@@ -90,7 +90,7 @@ import * as libsUtil from "../libs/util";
})
export default class QuickActionBvcBeginView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
attended = true;
gaveTime = true;
hoursStr = "1";
@@ -143,7 +143,7 @@ export default class QuickActionBvcBeginView extends Vue {
if (timeResult.type === "success") {
timeSuccess = true;
} else {
console.error("Error sending time:", timeResult);
logger.error("Error sending time:", timeResult);
this.$notify(
{
group: "alert",
@@ -170,7 +170,7 @@ export default class QuickActionBvcBeginView extends Vue {
if (attendResult.type === "success") {
attendedSuccess = true;
} else {
console.error("Error sending attendance:", attendResult);
logger.error("Error sending attendance:", attendResult);
this.$notify(
{
group: "alert",
@@ -201,12 +201,12 @@ export default class QuickActionBvcBeginView extends Vue {
},
3000,
);
(this.$router as Router).push({ path: "/quick-action-bvc" });
this.$router.push({ path: "/quick-action-bvc" });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error sending claims.", error);
logger.error("Error sending claims.", error);
this.$notify(
{
group: "alert",

View File

@@ -10,7 +10,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -22,16 +22,16 @@
<div>
<h2 class="text-2xl m-2">Confirm</h2>
<div v-if="loadingConfirms" class="flex justify-center">
<fa icon="spinner" class="fa-spin-pulse" />
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else-if="claimsToConfirm.length === 0">
There are no claims yet today for you to confirm.
</div>
<ul class="border-t border-slate-300 m-2">
<li
class="border-b border-slate-300 py-2"
v-for="record in claimsToConfirm"
:key="record.id"
class="border-b border-slate-300 py-2"
>
<div class="grid grid-cols-12">
<span class="col-span-11 justify-self-start">
@@ -39,6 +39,7 @@
<input
type="checkbox"
:checked="claimsToConfirmSelected.includes(record.id)"
class="mr-2 h-6 w-6"
@click="
claimsToConfirmSelected.includes(record.id)
? claimsToConfirmSelected.splice(
@@ -47,7 +48,6 @@
)
: claimsToConfirmSelected.push(record.id)
"
class="mr-2 h-6 w-6"
/>
</span>
{{
@@ -59,7 +59,7 @@
)
}}
<a @click="onClickLoadClaim(record.id)">
<fa
<font-awesome
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
/>
@@ -78,7 +78,7 @@
}}
so if you expected but do not see details from someone then ask them to
check that their activity is visible to you on their Contacts
<fa icon="users" class="text-slate-500" />
<font-awesome icon="users" class="text-slate-500" />
page.
</span>
</div>
@@ -96,18 +96,18 @@
<div>
<h2 class="text-2xl m-2">Anything else?</h2>
<div class="m-2 flex">
<input type="checkbox" v-model="someoneGave" class="h-6 w-6" />
<input v-model="someoneGave" type="checkbox" class="h-6 w-6" />
<span class="pb-2 pl-2 pr-2">The group provided</span>
<span v-if="someoneGave">
<input
type="text"
v-model="description"
type="text"
size="20"
class="border border-slate-400 h-6 px-2"
/>
<br />
(Everyone likes personalized messages! 😁 ... and for a pic:
<input type="checkbox" v-model="supplyGiftDetails" />)
<input v-model="supplyGiftDetails" type="checkbox" />)
</span>
<!-- This is to match input height to avoid shifting when hiding & showing. -->
<span v-else class="h-6">...</span>
@@ -119,8 +119,8 @@
class="flex justify-center mt-4"
>
<button
@click="record()"
class="block text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md w-56"
@click="record()"
>
Sign & Send
</button>
@@ -151,18 +151,20 @@ import {
retrieveSettingsForActiveAccount,
} from "../db/index";
import { Contact } from "../db/tables/contacts";
import {
GenericCredWrapper,
GenericVerifiableCredential,
ErrorResult,
} from "../interfaces";
import {
BVC_MEETUPS_PROJECT_CLAIM_ID,
claimSpecialDescription,
containsHiddenDid,
createAndSubmitConfirmation,
createAndSubmitGive,
GenericCredWrapper,
GenericVerifiableCredential,
getHeaders,
ErrorResult,
} from "../libs/endorserServer";
import { logger } from "../utils/logger";
@Component({
methods: { claimSpecialDescription },
components: {
@@ -227,7 +229,7 @@ export default class QuickActionBvcBeginView extends Vue {
);
if (!response.ok) {
console.error("Bad response", response);
logger.error("Bad response", response);
throw new Error("Bad response when retrieving claims.");
}
await response.json().then((data) => {
@@ -246,7 +248,7 @@ export default class QuickActionBvcBeginView extends Vue {
dataByOthers.length - dataByOthersWithoutHidden.length;
});
} catch (error) {
console.error("Error:", error);
logger.error("Error:", error);
this.$notify(
{
group: "alert",
@@ -298,7 +300,7 @@ export default class QuickActionBvcBeginView extends Vue {
result.status === "fulfilled" && result.value.type === "success",
);
if (confirmsSucceeded.length < this.claimsToConfirmSelected.length) {
console.error("Error sending confirmations:", confirmResults);
logger.error("Error sending confirmations:", confirmResults);
const howMany = confirmsSucceeded.length === 0 ? "all" : "some";
this.$notify(
{
@@ -331,7 +333,7 @@ export default class QuickActionBvcBeginView extends Vue {
);
giveSucceeded = giveResult.type === "success";
if (!giveSucceeded) {
console.error("Error sending give:", giveResult);
logger.error("Error sending give:", giveResult);
this.$notify(
{
group: "alert",
@@ -403,7 +405,7 @@ export default class QuickActionBvcBeginView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error sending claims.", error);
logger.error("Error sending claims.", error);
this.$notify(
{
group: "alert",

View File

@@ -10,7 +10,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -41,12 +41,14 @@ import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { Router } from "vue-router";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class QuickActionBvcView extends Vue {}
export default class QuickActionBvcView extends Vue {
$router!: Router;
}
</script>

View File

@@ -6,10 +6,10 @@
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<fa
<font-awesome
icon="chevron-left"
@click="$router.back()"
class="fa-fw text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
/>
Offers to Your Projects
</h1>
@@ -20,13 +20,13 @@
<p class="mt-2">
Maybe there are already some projects you can help on the
<router-link to="/discover" class="text-blue-500">
Discover page <fa icon="search" />
Discover page <font-awesome icon="search" />
</router-link>
</p>
<p class="mt-2">
You can announce more of your own on
<router-link to="/contacts" class="text-blue-500">
Your Ideas page <fa icon="hand" />
Your Ideas page <font-awesome icon="hand" />
</router-link>
</p>
</div>
@@ -42,8 +42,8 @@
class="mt-4 relative group"
>
<div
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="offer.jwtId == lastAckedOfferToUserProjectsJwtId"
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
>
You've already seen all the following
</div>
@@ -65,7 +65,10 @@
:to="{ path: '/claim/' + encodeURIComponent(offer.jwtId) }"
class="text-blue-500"
>
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" />
<font-awesome
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
/>
</router-link>
</li>
</ul>
@@ -83,20 +86,21 @@ import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { Router } from "vue-router";
import { OfferToPlanSummaryRecord } from "../interfaces";
import {
didInfo,
displayAmount,
getNewOffersToUserProjects,
OfferToPlanSummaryRecord,
} from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: { EntityIcon, GiftedDialog, InfiniteScroll, QuickNav },
})
export default class RecentOffersToUserView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
@@ -134,7 +138,7 @@ export default class RecentOffersToUserView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings & contacts:", err);
logger.error("Error retrieving settings & contacts:", err);
this.$notify(
{
group: "alert",

View File

@@ -6,10 +6,10 @@
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Back -->
<fa
<font-awesome
icon="chevron-left"
@click="$router.back()"
class="fa-fw text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
/>
Offers to You
</h1>
@@ -20,7 +20,7 @@
<p class="mt-2">
You can start the cycle on the
<router-link to="/contacts" class="text-blue-500">
Contacts page <fa icon="users" />
Contacts page <font-awesome icon="users" />
</router-link>
with an "Offer" directly to someone. Hopefully you'll find a common
interest!
@@ -37,8 +37,8 @@
class="mt-4 relative group"
>
<div
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="offer.jwtId == lastAckedOfferToUserJwtId"
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
>
You've already seen all the following
</div>
@@ -58,7 +58,10 @@
:to="{ path: '/claim/' + encodeURIComponent(offer.jwtId) }"
class="text-blue-500"
>
<fa icon="file-lines" class="pl-2 text-blue-500 cursor-pointer" />
<font-awesome
icon="file-lines"
class="pl-2 text-blue-500 cursor-pointer"
/>
</router-link>
</li>
</ul>
@@ -68,7 +71,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import GiftedDialog from "../components/GiftedDialog.vue";
import EntityIcon from "../components/EntityIcon.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue";
@@ -76,20 +79,20 @@ import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { OfferSummaryRecord } from "../interfaces";
import {
didInfo,
displayAmount,
getNewOffersToUser,
OfferSummaryRecord,
} from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: { EntityIcon, GiftedDialog, InfiniteScroll, QuickNav },
})
export default class RecentOffersToUserView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
@@ -126,7 +129,7 @@ export default class RecentOffersToUserView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings & contacts:", err);
logger.error("Error retrieving settings & contacts:", err);
this.$notify(
{
group: "alert",

View File

@@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -35,7 +35,7 @@
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="storeSearchBox"
>
<fa icon="save" class="fa-fw" />
<font-awesome icon="save" class="fa-fw" />
Store This Location for Nearby Search
</button>
<button
@@ -43,7 +43,7 @@
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="forgetSearchBox"
>
<fa icon="trash-can" class="fa-fw" />
<font-awesome icon="trash-can" class="fa-fw" />
Delete Stored Location
</button>
<button
@@ -51,7 +51,7 @@
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="resetLatLong"
>
<fa icon="rotate" class="fa-fw" />
<font-awesome icon="rotate" class="fa-fw" />
Reset To Original
</button>
<button
@@ -59,7 +59,7 @@
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
@click="isNewMarkerSet = false"
>
<fa icon="eraser" class="fa-fw" />
<font-awesome icon="eraser" class="fa-fw" />
Erase Marker
</button>
<div v-if="isNewMarkerSet">
@@ -71,9 +71,9 @@
<div class="aspect-video">
<l-map
ref="map"
v-model:zoom="localZoom"
:center="[localCenterLat, localCenterLong]"
class="!z-40 rounded-md"
v-model:zoom="localZoom"
@click="setMapPoint"
>
<l-tile-layer
@@ -115,7 +115,7 @@ import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { BoundingBox, MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { logger } from "../utils/logger";
const DEFAULT_LAT_LONG_DIFF = 0.01;
const WORLD_ZOOM = 2;
const DEFAULT_ZOOM = 2;
@@ -131,6 +131,7 @@ const DEFAULT_ZOOM = 2;
})
export default class SearchAreaView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
isChoosingSearchBox = false;
isNewMarkerSet = false;
@@ -219,7 +220,7 @@ export default class SearchAreaView extends Vue {
},
7000,
);
(this.$router as Router).back();
this.$router.back();
} catch (err) {
this.$notify(
{
@@ -230,7 +231,7 @@ export default class SearchAreaView extends Vue {
},
5000,
);
console.error(
logger.error(
"Telling user to retry the location search setting because:",
err,
);
@@ -273,7 +274,7 @@ export default class SearchAreaView extends Vue {
},
5000,
);
console.error(
logger.error(
"Telling user to retry the location search setting because:",
err,
);

View File

@@ -8,7 +8,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -62,7 +62,10 @@
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
></font-awesome>
</button>
<span v-show="showCopiedSeed" class="text-sm text-green-500">
Copied
@@ -79,7 +82,10 @@
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
<font-awesome
icon="copy"
class="text-slate-400 fa-fw"
></font-awesome>
</button>
<span v-show="showCopiedDeri" class="text-sm text-green-500"
>Copied</span
@@ -110,11 +116,12 @@ import {
retrieveAccountCount,
retrieveFullyDecryptedAccount,
} from "../libs/util";
import { Router } from "vue-router";
import { logger } from "../utils/logger";
@Component({ components: { QuickNav } })
export default class SeedBackupView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
activeAccount: Account | null | undefined = null;
numAccounts = 0;
showCopiedDeri = false;
@@ -130,7 +137,7 @@ export default class SeedBackupView extends Vue {
this.numAccounts = await retrieveAccountCount();
this.activeAccount = await retrieveFullyDecryptedAccount(activeDid);
} catch (err: unknown) {
console.error("Got an error loading an identifier:", err);
logger.error("Got an error loading an identifier:", err);
this.$notify(
{
group: "alert",

View File

@@ -12,7 +12,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw" />
<font-awesome icon="chevron-left" class="fa-fw" />
</h1>
</div>
@@ -34,7 +34,7 @@
<div class="mt-8">Click to copy your info, then send it to them.</div>
<div>
They will paste it in the input box on the Contacts
<fa icon="users" /> screen.
<font-awesome icon="users" /> screen.
</div>
</div>
</section>
@@ -50,15 +50,16 @@ import { NotificationIface, APP_SERVER } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { retrieveAccountMetadata } from "../libs/util";
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { logger } from "../utils/logger";
@Component({
components: { QuickNav, TopMessage },
})
export default class ShareMyContactInfoView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
mounted() {
console.log("APP_SERVER in mounted:", APP_SERVER);
logger.log("APP_SERVER in mounted:", APP_SERVER);
}
async onClickShare() {
@@ -106,7 +107,7 @@ export default class ShareMyContactInfoView extends Vue {
}, 3000);
}
});
(this.$router as Router).push({ name: "contacts" });
this.$router.push({ name: "contacts" });
} else {
this.$notify(
{

View File

@@ -8,30 +8,30 @@
</h1>
<div v-if="imageBlob">
<div v-if="uploading" class="text-center mb-4">
<fa icon="spinner" class="fa-spin-pulse" />
<font-awesome icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else>
<div class="text-center mb-4">Choose how to use this image</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button
@click="recordGift"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="recordGift"
>
<fa icon="gift" class="fa-fw" />
<font-awesome icon="gift" class="fa-fw" />
Record a Gift
</button>
<button
@click="recordProfile"
class="text-center text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="recordProfile"
>
<fa icon="circle-user" class="fa-fw" />
<font-awesome icon="circle-user" class="fa-fw" />
Save as Profile Image
</button>
<button
@click="cancel"
class="text-center text-md font-bold bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="cancel"
>
<fa icon="ban" class="fa-fw" />
<font-awesome icon="ban" class="fa-fw" />
Cancel
</button>
</div>
@@ -66,7 +66,11 @@
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationRaw, Router } from "vue-router";
import {
RouteLocationNormalizedLoaded,
RouteLocationRaw,
Router,
} from "vue-router";
import PhotoDialog from "../components/PhotoDialog.vue";
import QuickNav from "../components/QuickNav.vue";
@@ -79,10 +83,12 @@ import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { accessToken } from "../libs/crypto";
import { base64ToBlob, SHARED_PHOTO_BASE64_KEY } from "../libs/util";
import { logger } from "../utils/logger";
@Component({ components: { PhotoDialog, QuickNav } })
export default class SharedPhotoView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
activeDid: string | undefined = undefined;
imageBlob: Blob | undefined = undefined;
@@ -105,14 +111,12 @@ export default class SharedPhotoView extends Vue {
// clear the temp image
db.temp.delete(SHARED_PHOTO_BASE64_KEY);
this.imageFileName = (this.$route as Router).query[
"fileName"
] as string;
this.imageFileName = this.$route.query["fileName"] as string;
} else {
console.error("No appropriate image found in temp storage.", temp);
logger.error("No appropriate image found in temp storage.", temp);
}
} catch (err: unknown) {
console.error("Got an error loading an identifier:", err);
logger.error("Got an error loading an identifier:", err);
this.$notify(
{
group: "alert",
@@ -138,7 +142,7 @@ export default class SharedPhotoView extends Vue {
recipientDid: this.activeDid,
},
} as RouteLocationRaw;
(this.$router as Router).push(route);
this.$router.push(route);
}
});
}
@@ -149,7 +153,7 @@ export default class SharedPhotoView extends Vue {
await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: imgUrl,
});
(this.$router as Router).push({ name: "account" });
this.$router.push({ name: "account" });
},
IMAGE_TYPE_PROFILE,
true,
@@ -161,7 +165,7 @@ export default class SharedPhotoView extends Vue {
async cancel() {
this.imageBlob = undefined;
this.imageFileName = undefined;
(this.$router as Router).push({ name: "home" });
this.$router.push({ name: "home" });
}
async sendToImageServer(imageType: string) {
@@ -187,7 +191,7 @@ export default class SharedPhotoView extends Vue {
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
console.log(
logger.log(
"Using shared image API server, so only users on that server can play with images.",
);
}
@@ -201,7 +205,7 @@ export default class SharedPhotoView extends Vue {
this.imageFileName = undefined;
result = response.data.url as string;
} else {
console.error("Problem uploading the image", response.data);
logger.error("Problem uploading the image", response.data);
this.$notify(
{
group: "alert",
@@ -217,7 +221,7 @@ export default class SharedPhotoView extends Vue {
this.uploading = false;
} catch (error) {
console.error("Error uploading the image", error);
logger.error("Error uploading the image", error);
this.$notify(
{
group: "alert",

View File

@@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -34,7 +34,7 @@
href="https://www.perplexity.ai/search/what-are-passkeys-v2SHV3yLQlyA2CYH6.Nvhg"
target="_blank"
>
<fa icon="info-circle" class="fa-fw text-blue-500" />
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
</a>
</p>
<p class="text-center font-light mt-4">
@@ -44,21 +44,21 @@
href="https://www.perplexity.ai/search/what-is-a-seed-phrase-OqiP9foVRXidr_2le5OFKA"
target="_blank"
>
<fa icon="info-circle" class="fa-fw text-blue-500" />
<font-awesome icon="info-circle" class="fa-fw text-blue-500" />
</a>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
<a
v-if="PASSKEYS_ENABLED"
@click="onClickNewPasskey()"
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer"
@click="onClickNewPasskey()"
>
Generate one with a passkey
</a>
<a
@click="onClickNewSeed()"
class="block w-full text-center text-lg uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2 cursor-pointer"
data-testId="newSeed"
@click="onClickNewSeed()"
>
Generate one with a new seed
</a>
@@ -69,15 +69,15 @@
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<a
@click="onClickNo()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer"
@click="onClickNo()"
>
You have a seed
</a>
<a
v-if="numAccounts > 0"
@click="onClickDerive()"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer"
@click="onClickDerive()"
>
Derive new address from existing seed
</a>
@@ -102,6 +102,7 @@ import {
components: {},
})
export default class StartView extends Vue {
$router!: Router;
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
givenName = "";
@@ -115,22 +116,22 @@ export default class StartView extends Vue {
}
public onClickNewSeed() {
(this.$router as Router).push({ name: "new-identifier" });
this.$router.push({ name: "new-identifier" });
}
public async onClickNewPasskey() {
const keyName =
AppString.APP_NAME + (this.givenName ? " - " + this.givenName : "");
await registerSaveAndActivatePasskey(keyName);
(this.$router as Router).push({ name: "account" });
this.$router.push({ name: "account" });
}
public onClickNo() {
(this.$router as Router).push({ name: "import-account" });
this.$router.push({ name: "import-account" });
}
public onClickDerive() {
(this.$router as Router).push({ name: "import-derive" });
this.$router.push({ name: "import-derive" });
}
}
</script>

View File

@@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -56,7 +56,7 @@
<script lang="ts">
import { SVGRenderer } from "three/examples/jsm/renderers/SVGRenderer.js";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { World } from "../components/World/World.js";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -72,6 +72,7 @@ interface Dictionary<T> {
@Component({ components: { World, QuickNav } })
export default class StatisticsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
world: World;
worldProperties: Dictionary<number> = {};

View File

@@ -11,7 +11,7 @@
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
@@ -25,8 +25,9 @@
<h2 class="text-xl font-bold mb-4">Notiwind Alerts</h2>
<button
class="font-bold capitalize bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
@click="
this.$notify(
$notify(
{
group: 'alert',
type: 'toast',
@@ -36,14 +37,14 @@
5000,
)
"
class="font-bold capitalize bg-slate-900 text-white px-3 py-2 rounded-md mr-2"
>
Toast
</button>
<button
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
@click="
this.$notify(
$notify(
{
group: 'alert',
type: 'info',
@@ -53,14 +54,14 @@
5000,
)
"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Info
</button>
<button
class="font-bold capitalize bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
@click="
this.$notify(
$notify(
{
group: 'alert',
type: 'success',
@@ -70,14 +71,14 @@
5000,
)
"
class="font-bold capitalize bg-emerald-600 text-white px-3 py-2 rounded-md mr-2"
>
Success
</button>
<button
class="font-bold capitalize bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
@click="
this.$notify(
$notify(
{
group: 'alert',
type: 'warning',
@@ -87,14 +88,14 @@
5000,
)
"
class="font-bold capitalize bg-amber-600 text-white px-3 py-2 rounded-md mr-2"
>
Warning
</button>
<button
class="font-bold capitalize bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
@click="
this.$notify(
$notify(
{
group: 'alert',
type: 'danger',
@@ -104,14 +105,14 @@
5000,
)
"
class="font-bold capitalize bg-rose-600 text-white px-3 py-2 rounded-md mr-2"
>
Danger
</button>
<button
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
@click="
this.$notify(
$notify(
{
group: 'modal',
type: 'notification-permission',
@@ -121,14 +122,14 @@
-1,
)
"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif ON
</button>
<button
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
@click="
this.$notify(
$notify(
{
group: 'modal',
type: 'notification-mute',
@@ -138,14 +139,14 @@
-1,
)
"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif MUTE
</button>
<button
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
@click="
this.$notify(
$notify(
{
group: 'modal',
type: 'notification-off',
@@ -155,7 +156,6 @@
-1,
)
"
class="font-bold capitalize bg-slate-600 text-white px-3 py-2 rounded-md mr-2"
>
Notif OFF
</button>
@@ -190,8 +190,8 @@
<div>
Register Passkey
<button
@click="register()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="register()"
>
Simplewebauthn
</button>
@@ -200,14 +200,14 @@
<div>
Create JWT
<button
@click="createJwtSimplewebauthn()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="createJwtSimplewebauthn()"
>
Simplewebauthn
</button>
<button
@click="createJwtNavigator()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="createJwtNavigator()"
>
Navigator
</button>
@@ -216,28 +216,28 @@
<div v-if="jwt">
Verify New JWT
<button
@click="verifySimplewebauthn()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="verifySimplewebauthn()"
>
Simplewebauthn
</button>
<button
@click="verifyWebCrypto()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="verifyWebCrypto()"
>
WebCrypto
</button>
<button
@click="verifyP256()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="verifyP256()"
>
p256 - broken
</button>
</div>
<div v-else>Verify New JWT -- requires creation first</div>
<button
@click="verifyMyJwt()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="verifyMyJwt()"
>
Verify Hard-Coded JWT
</button>
@@ -248,8 +248,8 @@
See console for more output.
<div>
<button
@click="testEncryptionDecryption()"
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testEncryptionDecryption()"
>
Run Test
</button>
@@ -285,7 +285,7 @@ import {
registerAndSavePasskey,
SHARED_PHOTO_BASE64_KEY,
} from "../libs/util";
import { logger } from "../utils/logger";
const inputFileNameRef = ref<Blob>();
const TEST_PAYLOAD = {
@@ -378,7 +378,7 @@ export default class Help extends Vue {
this.userName = DEFAULT_USERNAME;
},
onYes: async () => {
(this.$router as Router).push({ name: "new-edit-account" });
this.$router.push({ name: "new-edit-account" });
},
noText: "try again and use " + DEFAULT_USERNAME,
},
@@ -411,7 +411,7 @@ export default class Help extends Vue {
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("simple jwt4url", this.jwt);
logger.log("simple jwt4url", this.jwt);
}
public async createJwtNavigator() {
@@ -428,7 +428,7 @@ export default class Help extends Vue {
TEST_PAYLOAD,
this.credIdHex as string,
);
console.log("lower jwt4url", this.jwt);
logger.log("lower jwt4url", this.jwt);
}
public async verifyP256() {
@@ -440,7 +440,7 @@ export default class Help extends Vue {
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup?.signature as Base64URLString,
);
console.log("decoded", decoded);
logger.log("decoded", decoded);
}
public async verifySimplewebauthn() {
@@ -452,7 +452,7 @@ export default class Help extends Vue {
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup?.signature as Base64URLString,
);
console.log("decoded", decoded);
logger.log("decoded", decoded);
}
public async verifyWebCrypto() {
@@ -464,7 +464,7 @@ export default class Help extends Vue {
this.peerSetup?.clientDataJsonBase64Url as Base64URLString,
this.peerSetup?.signature as Base64URLString,
);
console.log("decoded", decoded);
logger.log("decoded", decoded);
}
public async verifyMyJwt() {
@@ -490,7 +490,7 @@ export default class Help extends Vue {
payload["ClientDataJSONB64URL"],
signatureB64URL,
);
console.log("decoded", decoded);
logger.log("decoded", decoded);
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More