forked from jsnbuchanan/crowd-funder-for-time-pwa
Merge branch 'master' into gifting-periphery-improvements
This commit is contained in:
75
src/assets/icons.json
Normal file
75
src/assets/icons.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"warning": {
|
||||
"fillRule": "evenodd",
|
||||
"d": "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z",
|
||||
"clipRule": "evenodd"
|
||||
},
|
||||
"spinner": {
|
||||
"d": "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
},
|
||||
"chart": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
},
|
||||
"plus": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M12 4v16m8-8H4"
|
||||
},
|
||||
"settings": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
},
|
||||
"settingsDot": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
},
|
||||
"lock": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
},
|
||||
"download": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
},
|
||||
"check": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
},
|
||||
"edit": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
},
|
||||
"trash": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
},
|
||||
"plusCircle": {
|
||||
"strokeLinecap": "round",
|
||||
"strokeLinejoin": "round",
|
||||
"strokeWidth": "2",
|
||||
"d": "M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
},
|
||||
"info": {
|
||||
"fillRule": "evenodd",
|
||||
"d": "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z",
|
||||
"clipRule": "evenodd"
|
||||
}
|
||||
}
|
||||
@@ -853,7 +853,7 @@ export default class GiftedDialog extends Vue {
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -899,19 +899,6 @@ export default class GiftedDialog extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -48,12 +48,15 @@
|
||||
<span>
|
||||
{{ didInfo(visDid) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||
<a :href="`/did/${visDid}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{ path: '/did/' + encodeURIComponent(visDid) }"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -74,7 +77,7 @@
|
||||
If you'd like an introduction,
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', deepLinkUrl)"
|
||||
>click here to copy this page, paste it into a message, and ask if
|
||||
they'll tell you more about the {{ roleName }}.</a
|
||||
>
|
||||
@@ -101,7 +104,7 @@ import * as R from "ramda";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface } from "../constants/app";
|
||||
|
||||
@Component
|
||||
export default class HiddenDidDialog extends Vue {
|
||||
@@ -114,7 +117,8 @@ export default class HiddenDidDialog extends Vue {
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
canShare = false;
|
||||
windowLocation = window.location.href;
|
||||
deepLinkPathSuffix = "";
|
||||
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
|
||||
|
||||
R = R;
|
||||
serverUtil = serverUtil;
|
||||
@@ -126,17 +130,21 @@ export default class HiddenDidDialog extends Vue {
|
||||
}
|
||||
|
||||
open(
|
||||
deepLinkPathSuffix: string,
|
||||
roleName: string,
|
||||
visibleToDids: string[],
|
||||
allContacts: Array<Contact>,
|
||||
activeDid: string,
|
||||
allMyDids: Array<string>,
|
||||
) {
|
||||
this.deepLinkPathSuffix = deepLinkPathSuffix;
|
||||
this.roleName = roleName;
|
||||
this.visibleToDids = visibleToDids;
|
||||
this.allContacts = allContacts;
|
||||
this.activeDid = activeDid;
|
||||
this.allMyDids = allMyDids;
|
||||
|
||||
this.deepLinkUrl = APP_SERVER + "/deep-link/" + this.deepLinkPathSuffix;
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
@@ -170,11 +178,11 @@ export default class HiddenDidDialog extends Vue {
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||
this.copyToClipboard("A link to this page", this.deepLinkUrl);
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||
url: this.windowLocation,
|
||||
url: this.deepLinkUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
90
src/components/IconRenderer.vue
Normal file
90
src/components/IconRenderer.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<svg
|
||||
v-if="iconData"
|
||||
:class="svgClass"
|
||||
:fill="fill"
|
||||
:stroke="stroke"
|
||||
:viewBox="viewBox"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path v-for="(path, index) in iconData.paths" :key="index" v-bind="path" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import icons from "../assets/icons.json";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* Icon path interface
|
||||
*/
|
||||
interface IconPath {
|
||||
d: string;
|
||||
fillRule?: string;
|
||||
clipRule?: string;
|
||||
strokeLinecap?: string;
|
||||
strokeLinejoin?: string;
|
||||
strokeWidth?: string | number;
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon data interface
|
||||
*/
|
||||
interface IconData {
|
||||
paths: IconPath[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Icons JSON structure
|
||||
*/
|
||||
interface IconsJson {
|
||||
[key: string]: IconPath | IconData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon Renderer Component
|
||||
*
|
||||
* This component loads SVG icon definitions from a JSON file and renders them
|
||||
* as SVG elements. It provides a clean way to use icons without cluttering
|
||||
* templates with long SVG path definitions.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
* @since 2024
|
||||
*/
|
||||
@Component({
|
||||
name: "IconRenderer",
|
||||
})
|
||||
export default class IconRenderer extends Vue {
|
||||
@Prop({ required: true }) readonly iconName!: string;
|
||||
@Prop({ default: "h-5 w-5" }) readonly svgClass!: string;
|
||||
@Prop({ default: "none" }) readonly fill!: string;
|
||||
@Prop({ default: "currentColor" }) readonly stroke!: string;
|
||||
@Prop({ default: "0 0 24 24" }) readonly viewBox!: string;
|
||||
|
||||
/**
|
||||
* Get the icon data for the specified icon name
|
||||
*
|
||||
* @returns {IconData | null} The icon data object or null if not found
|
||||
*/
|
||||
get iconData(): IconData | null {
|
||||
const icon = (icons as IconsJson)[this.iconName];
|
||||
if (!icon) {
|
||||
logger.warn(`Icon "${this.iconName}" not found in icons.json`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert single path to array format for consistency
|
||||
if ("d" in icon) {
|
||||
return {
|
||||
paths: [icon as IconPath],
|
||||
};
|
||||
}
|
||||
|
||||
return icon as IconData;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -83,10 +83,7 @@
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import {
|
||||
createAndSubmitOffer,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import { createAndSubmitOffer } from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
@@ -250,7 +247,7 @@ export default class OfferDialog extends Vue {
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -290,21 +287,6 @@ export default class OfferDialog extends Vue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getOfferCreationErrorMessage(result: any) {
|
||||
return (
|
||||
serverMessageForUser(result) ||
|
||||
result.error?.userMessage ||
|
||||
result.error?.error
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -38,14 +38,13 @@ export default class TopMessage extends Vue {
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message = "You're linked to a non-prod server, user " + didPrefix;
|
||||
this.message = "You're not using prod, user " + didPrefix;
|
||||
} else if (
|
||||
settings.warnIfProdServer &&
|
||||
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message =
|
||||
"You're linked to the production server, user " + didPrefix;
|
||||
this.message = "You are using prod, user " + didPrefix;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.$notify(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import migrationService from "../services/migrationService";
|
||||
import {
|
||||
registerMigration,
|
||||
runMigrations as runMigrationsService,
|
||||
} from "../services/migrationService";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
import { arrayBufferToBase64 } from "@/libs/crypto";
|
||||
|
||||
@@ -31,7 +34,6 @@ const secretBase64 = arrayBufferToBase64(randomBytes);
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
name: "001_initial",
|
||||
// see ../db/tables files for explanations of the fields
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -116,6 +118,12 @@ const MIGRATIONS = [
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "002_add_iViewContent_to_contacts",
|
||||
sql: `
|
||||
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -123,16 +131,12 @@ const MIGRATIONS = [
|
||||
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
|
||||
*/
|
||||
export async function runMigrations<T>(
|
||||
sqlExec: (sql: string) => Promise<unknown>,
|
||||
sqlQuery: (sql: string) => Promise<T>,
|
||||
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
|
||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
||||
extractMigrationNames: (result: T) => Set<string>,
|
||||
): Promise<void> {
|
||||
for (const migration of MIGRATIONS) {
|
||||
migrationService.registerMigration(migration);
|
||||
registerMigration(migration);
|
||||
}
|
||||
await migrationService.runMigrations(
|
||||
sqlExec,
|
||||
sqlQuery,
|
||||
extractMigrationNames,
|
||||
);
|
||||
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
|
||||
}
|
||||
|
||||
@@ -219,18 +219,36 @@ export async function logConsoleAndDb(
|
||||
isError = false,
|
||||
): Promise<void> {
|
||||
if (isError) {
|
||||
logger.error(`${new Date().toISOString()} ${message}`);
|
||||
logger.error(`${new Date().toISOString()}`, message);
|
||||
} else {
|
||||
logger.log(`${new Date().toISOString()} ${message}`);
|
||||
logger.log(`${new Date().toISOString()}`, message);
|
||||
}
|
||||
await logToDb(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an SQL INSERT statement and parameters from a model object.
|
||||
* @param model The model object containing fields to update
|
||||
* @param tableName The name of the table to update
|
||||
* @returns Object containing the SQL statement and parameters array
|
||||
* Generates SQL INSERT statement and parameters from a model object
|
||||
*
|
||||
* This helper function creates a parameterized SQL INSERT statement
|
||||
* from a JavaScript object. It filters out undefined values and
|
||||
* creates the appropriate SQL syntax with placeholders.
|
||||
*
|
||||
* The function is used internally by the migration functions to
|
||||
* safely insert data into the SQLite database.
|
||||
*
|
||||
* @function generateInsertStatement
|
||||
* @param {Record<string, unknown>} model - The model object containing fields to insert
|
||||
* @param {string} tableName - The name of the table to insert into
|
||||
* @returns {Object} Object containing the SQL statement and parameters array
|
||||
* @returns {string} returns.sql - The SQL INSERT statement
|
||||
* @returns {unknown[]} returns.params - Array of parameter values
|
||||
* @example
|
||||
* ```typescript
|
||||
* const contact = { did: 'did:example:123', name: 'John Doe' };
|
||||
* const { sql, params } = generateInsertStatement(contact, 'contacts');
|
||||
* // sql: "INSERT INTO contacts (did, name) VALUES (?, ?)"
|
||||
* // params: ['did:example:123', 'John Doe']
|
||||
* ```
|
||||
*/
|
||||
export function generateInsertStatement(
|
||||
model: Record<string, unknown>,
|
||||
@@ -248,12 +266,30 @@ export function generateInsertStatement(
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an SQL UPDATE statement and parameters from a model object.
|
||||
* @param model The model object containing fields to update
|
||||
* @param tableName The name of the table to update
|
||||
* @param whereClause The WHERE clause for the update (e.g. "id = ?")
|
||||
* @param whereParams Parameters for the WHERE clause
|
||||
* @returns Object containing the SQL statement and parameters array
|
||||
* Generates SQL UPDATE statement and parameters from a model object
|
||||
*
|
||||
* This helper function creates a parameterized SQL UPDATE statement
|
||||
* from a JavaScript object. It filters out undefined values and
|
||||
* creates the appropriate SQL syntax with placeholders.
|
||||
*
|
||||
* The function is used internally by the migration functions to
|
||||
* safely update data in the SQLite database.
|
||||
*
|
||||
* @function generateUpdateStatement
|
||||
* @param {Record<string, unknown>} model - The model object containing fields to update
|
||||
* @param {string} tableName - The name of the table to update
|
||||
* @param {string} whereClause - The WHERE clause for the update (e.g. "id = ?")
|
||||
* @param {unknown[]} [whereParams=[]] - Parameters for the WHERE clause
|
||||
* @returns {Object} Object containing the SQL statement and parameters array
|
||||
* @returns {string} returns.sql - The SQL UPDATE statement
|
||||
* @returns {unknown[]} returns.params - Array of parameter values
|
||||
* @example
|
||||
* ```typescript
|
||||
* const contact = { name: 'Jane Doe' };
|
||||
* const { sql, params } = generateUpdateStatement(contact, 'contacts', 'did = ?', ['did:example:123']);
|
||||
* // sql: "UPDATE contacts SET name = ? WHERE did = ?"
|
||||
* // params: ['Jane Doe', 'did:example:123']
|
||||
* ```
|
||||
*/
|
||||
export function generateUpdateStatement(
|
||||
model: Record<string, unknown>,
|
||||
|
||||
@@ -1 +1 @@
|
||||
Check the contact & settings export to see whether you want your new table to be included in it.
|
||||
# Check the contact & settings export to see whether you want your new table to be included in it
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
export interface ContactMethod {
|
||||
export type ContactMethod = {
|
||||
label: string;
|
||||
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
|
||||
value: string;
|
||||
}
|
||||
};
|
||||
|
||||
export interface Contact {
|
||||
export type Contact = {
|
||||
//
|
||||
// When adding a property, consider whether it should be added when exporting & sharing contacts.
|
||||
// When adding a property, consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
|
||||
|
||||
did: string;
|
||||
contactMethods?: Array<ContactMethod>;
|
||||
iViewContent?: boolean;
|
||||
name?: string;
|
||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||
notes?: string;
|
||||
@@ -17,7 +18,17 @@ export interface Contact {
|
||||
publicKeyBase64?: string;
|
||||
seesMe?: boolean; // cached value of the server setting
|
||||
registered?: boolean; // cached value of the server setting
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This is for those cases (eg. with a DB) where every field is a primitive (and not an object).
|
||||
*
|
||||
* This is so that we can reuse most of the type and don't have to maintain another copy.
|
||||
* Another approach uses typescript conditionals: https://chatgpt.com/share/6855cdc3-ab5c-8007-8525-726612016eb2
|
||||
*/
|
||||
export type ContactWithJsonStrings = Omit<Contact, "contactMethods"> & {
|
||||
contactMethods?: string;
|
||||
};
|
||||
|
||||
export const ContactSchema = {
|
||||
contacts: "&did, name", // no need to key by other things
|
||||
|
||||
@@ -64,6 +64,11 @@ export type Settings = {
|
||||
webPushServer?: string; // Web Push server URL
|
||||
};
|
||||
|
||||
// type of settings where the searchBoxes are JSON strings instead of objects
|
||||
export type SettingsWithJsonStrings = Settings & {
|
||||
searchBoxes: string;
|
||||
};
|
||||
|
||||
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
|
||||
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GiverReceiverInputInfo } from "../libs/util";
|
||||
import { ErrorResult, ResultWithType } from "./common";
|
||||
|
||||
export interface GiverOutputInfo {
|
||||
action: string;
|
||||
@@ -47,12 +45,3 @@ export interface ProviderInfo {
|
||||
*/
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
|
||||
publicUrls?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ResultWithType {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error?: {
|
||||
message?: string;
|
||||
@@ -30,11 +26,6 @@ export interface InternalError {
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
export interface ErrorResult extends ResultWithType {
|
||||
type: "error";
|
||||
error: InternalError;
|
||||
}
|
||||
|
||||
export interface KeyMeta {
|
||||
did: string;
|
||||
publicKeyHex: string;
|
||||
|
||||
@@ -29,18 +29,17 @@ import { z } from "zod";
|
||||
|
||||
// Add a union type of all valid route paths
|
||||
export const VALID_DEEP_LINK_ROUTES = [
|
||||
"user-profile",
|
||||
"project-details",
|
||||
"onboard-meeting-setup",
|
||||
"invite-one-accept",
|
||||
"contact-import",
|
||||
"confirm-gift",
|
||||
// note that similar lists are below in deepLinkSchemas and in src/services/deepLinks.ts
|
||||
"claim",
|
||||
"claim-cert",
|
||||
"claim-add-raw",
|
||||
"contact-edit",
|
||||
"contacts",
|
||||
"claim-cert",
|
||||
"confirm-gift",
|
||||
"contact-import",
|
||||
"did",
|
||||
"invite-one-accept",
|
||||
"onboard-meeting-setup",
|
||||
"project",
|
||||
"user-profile",
|
||||
] as const;
|
||||
|
||||
// Create a type from the array
|
||||
@@ -58,44 +57,39 @@ export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
|
||||
|
||||
// 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(),
|
||||
}),
|
||||
// note that similar lists are above in VALID_DEEP_LINK_ROUTES and in src/services/deepLinks.ts
|
||||
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(),
|
||||
"claim-cert": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
contacts: z.object({
|
||||
contacts: z.string(), // JSON string of contacts array
|
||||
"confirm-gift": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"contact-import": z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
did: z.object({
|
||||
did: z.string(),
|
||||
}),
|
||||
"invite-one-accept": z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
"onboard-meeting-setup": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
project: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
"user-profile": z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export type DeepLinkParams = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type {
|
||||
// From common.ts
|
||||
CreateAndSubmitClaimResult,
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
KeyMeta,
|
||||
@@ -18,11 +19,6 @@ export type {
|
||||
RegisterActionClaim,
|
||||
} from "./claims";
|
||||
|
||||
export type {
|
||||
// From claims-result.ts
|
||||
CreateAndSubmitClaimResult,
|
||||
} from "./claims-result";
|
||||
|
||||
export type {
|
||||
// From records.ts
|
||||
PlanSummaryRecord,
|
||||
|
||||
@@ -979,7 +979,7 @@ export const createAndSubmitConfirmation = async (
|
||||
handleId: string | undefined,
|
||||
apiServer: string,
|
||||
axios: Axios,
|
||||
) => {
|
||||
): Promise<CreateAndSubmitClaimResult> => {
|
||||
const goodClaim = removeSchemaContext(
|
||||
removeVisibleToDids(
|
||||
addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId),
|
||||
@@ -1074,7 +1074,8 @@ export async function generateEndorserJwtUrlForAccount(
|
||||
|
||||
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
|
||||
|
||||
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
|
||||
const viewPrefix =
|
||||
APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
|
||||
return viewPrefix + vcJwt;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faArrowUpRightFromSquare,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
@@ -95,8 +95,8 @@ library.add(
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faArrowUpRightFromSquare,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
|
||||
126
src/libs/util.ts
126
src/libs/util.ts
@@ -17,7 +17,7 @@ import {
|
||||
updateDefaultSettings,
|
||||
} from "../db/index";
|
||||
import { Account, AccountEncrypted } from "../db/tables/accounts";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { Contact, ContactWithJsonStrings } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
|
||||
import {
|
||||
@@ -45,6 +45,7 @@ import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { sha256 } from "ethereum-cryptography/sha256";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { insertDidSpecificSettings, parseJsonField } from "../db/databaseUtil";
|
||||
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
|
||||
|
||||
export interface GiverReceiverInputInfo {
|
||||
did?: string;
|
||||
@@ -884,6 +885,71 @@ export const contactToCsvLine = (contact: Contact): string => {
|
||||
return fields.join(",");
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a CSV line into a Contact object. See contactToCsvLine for the format.
|
||||
* @param lineRaw - The CSV line to parse
|
||||
* @returns A Contact object
|
||||
*/
|
||||
export const csvLineToContact = (lineRaw: string): Contact => {
|
||||
// Note that Endorser Mobile puts name first, then did, etc.
|
||||
let line = lineRaw.trim();
|
||||
let did, publicKeyInput, seesMe, registered;
|
||||
let name;
|
||||
let commaPos1 = -1;
|
||||
if (line.startsWith('"')) {
|
||||
let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
|
||||
if (doubleDoubleQuotePos === -1) {
|
||||
doubleDoubleQuotePos = 1;
|
||||
}
|
||||
const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
|
||||
if (quote2Pos > -1) {
|
||||
commaPos1 = line.indexOf(",", quote2Pos);
|
||||
name = line.substring(1, quote2Pos).trim();
|
||||
name = name.replace(/""/g, '"');
|
||||
} else {
|
||||
// something is weird with one " to start, so ignore it and start after "
|
||||
line = line.substring(1);
|
||||
commaPos1 = line.indexOf(",");
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
}
|
||||
} else {
|
||||
commaPos1 = line.indexOf(",");
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
}
|
||||
if (commaPos1 > -1) {
|
||||
did = line.substring(commaPos1 + 1).trim();
|
||||
const commaPos2 = line.indexOf(",", commaPos1 + 1);
|
||||
if (commaPos2 > -1) {
|
||||
did = line.substring(commaPos1 + 1, commaPos2).trim();
|
||||
publicKeyInput = line.substring(commaPos2 + 1).trim();
|
||||
const commaPos3 = line.indexOf(",", commaPos2 + 1);
|
||||
if (commaPos3 > -1) {
|
||||
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
|
||||
seesMe = line.substring(commaPos3 + 1).trim() == "true";
|
||||
const commaPos4 = line.indexOf(",", commaPos3 + 1);
|
||||
if (commaPos4 > -1) {
|
||||
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
|
||||
registered = line.substring(commaPos4 + 1).trim() == "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// help with potential mistakes while this sharing requires copy-and-paste
|
||||
let publicKeyBase64 = publicKeyInput;
|
||||
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
|
||||
// it must be all hex (compressed public key), so convert
|
||||
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
||||
}
|
||||
const newContact: Contact = {
|
||||
did: did || "",
|
||||
name,
|
||||
publicKeyBase64,
|
||||
seesMe,
|
||||
registered,
|
||||
};
|
||||
return newContact;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for the JSON export format of database tables
|
||||
*/
|
||||
@@ -910,19 +976,16 @@ export interface DatabaseExport {
|
||||
*/
|
||||
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
|
||||
// Convert each contact to a plain object and ensure all fields are included
|
||||
const rows = contacts.map((contact) => ({
|
||||
did: contact.did,
|
||||
name: contact.name || null,
|
||||
contactMethods: contact.contactMethods
|
||||
? JSON.stringify(parseJsonField(contact.contactMethods, []))
|
||||
: null,
|
||||
nextPubKeyHashB64: contact.nextPubKeyHashB64 || null,
|
||||
notes: contact.notes || null,
|
||||
profileImageUrl: contact.profileImageUrl || null,
|
||||
publicKeyBase64: contact.publicKeyBase64 || null,
|
||||
seesMe: contact.seesMe || false,
|
||||
registered: contact.registered || false,
|
||||
}));
|
||||
const rows = contacts.map((contact) => {
|
||||
const exContact: ContactWithJsonStrings = R.omit(
|
||||
["contactMethods"],
|
||||
contact,
|
||||
);
|
||||
exContact.contactMethods = contact.contactMethods
|
||||
? JSON.stringify(contact.contactMethods, [])
|
||||
: undefined;
|
||||
return exContact;
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
@@ -935,3 +998,38 @@ export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Imports an account from a mnemonic phrase
|
||||
* @param mnemonic - The seed phrase to import from
|
||||
* @param derivationPath - The derivation path to use (defaults to DEFAULT_ROOT_DERIVATION_PATH)
|
||||
* @param shouldErase - Whether to erase existing accounts before importing
|
||||
* @returns Promise that resolves when import is complete
|
||||
* @throws Error if mnemonic is invalid or import fails
|
||||
*/
|
||||
export async function importFromMnemonic(
|
||||
mnemonic: string,
|
||||
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
|
||||
shouldErase: boolean = false,
|
||||
): Promise<void> {
|
||||
const mne: string = mnemonic.trim().toLowerCase();
|
||||
|
||||
// Derive address and keys from mnemonic
|
||||
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
|
||||
|
||||
// Create new identifier
|
||||
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
||||
|
||||
// Handle erasures
|
||||
if (shouldErase) {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec("DELETE FROM accounts");
|
||||
if (USE_DEXIE_DB) {
|
||||
const accountsDB = await accountsDBPromise;
|
||||
await accountsDB.accounts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Save the new identity
|
||||
await saveNewIdentity(newId, mne, derivationPath);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,7 @@ import router from "./router";
|
||||
import { handleApiError } from "./services/api";
|
||||
import { AxiosError } from "axios";
|
||||
import { DeepLinkHandler } from "./services/deepLinks";
|
||||
import { logConsoleAndDb } from "./db/databaseUtil";
|
||||
import { logger } from "./utils/logger";
|
||||
import { logger, safeStringify } from "./utils/logger";
|
||||
|
||||
logger.log("[Capacitor] Starting initialization");
|
||||
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
|
||||
@@ -72,10 +71,10 @@ const handleDeepLink = async (data: { url: string }) => {
|
||||
await router.isReady();
|
||||
await deepLinkHandler.handleDeepLink(data.url);
|
||||
} catch (error) {
|
||||
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true);
|
||||
logger.error("[DeepLink] Error handling deep link: ", error);
|
||||
handleApiError(
|
||||
{
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: error instanceof Error ? error.message : safeStringify(error),
|
||||
} as AxiosError,
|
||||
"deep-link",
|
||||
);
|
||||
|
||||
@@ -10,15 +10,11 @@ import { FontAwesomeIcon } from "./libs/fontawesome";
|
||||
import Camera from "simple-vue-camera";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
const platform = process.env.VITE_PLATFORM;
|
||||
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
|
||||
|
||||
logger.log("Platform", JSON.stringify({ platform }));
|
||||
logger.log("PWA enabled", JSON.stringify({ pwa_enabled }));
|
||||
// const platform = process.env.VITE_PLATFORM;
|
||||
// const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
|
||||
|
||||
// Global Error Handler
|
||||
function setupGlobalErrorHandler(app: VueApp) {
|
||||
logger.log("[App Init] Setting up global error handler");
|
||||
app.config.errorHandler = (
|
||||
err: unknown,
|
||||
instance: ComponentPublicInstance | null,
|
||||
@@ -38,30 +34,13 @@ function setupGlobalErrorHandler(app: VueApp) {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ import { logger } from "./utils/logger";
|
||||
const platform = process.env.VITE_PLATFORM;
|
||||
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
|
||||
|
||||
logger.info("[Web] PWA enabled", { pwa_enabled });
|
||||
logger.info("[Web] Platform", { platform });
|
||||
|
||||
// Only import service worker for web builds
|
||||
if (platform !== "electron" && pwa_enabled) {
|
||||
import("./registerServiceWorker"); // Web PWA support
|
||||
@@ -31,7 +28,7 @@ function sqlInit() {
|
||||
if (platform === "web" || platform === "development") {
|
||||
sqlInit();
|
||||
} else {
|
||||
logger.info("[Web] SQL not initialized for platform", { platform });
|
||||
logger.warn("[Web] SQL not initialized for platform", { platform });
|
||||
}
|
||||
|
||||
app.mount("#app");
|
||||
|
||||
@@ -83,6 +83,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "discover",
|
||||
component: () => import("../views/DiscoverView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/deep-link/:path*",
|
||||
name: "deep-link",
|
||||
component: () => import("../views/DeepLinkRedirectView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/gifted-details",
|
||||
name: "gifted-details",
|
||||
@@ -143,6 +148,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "logs",
|
||||
component: () => import("../views/LogView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/database-migration",
|
||||
name: "database-migration",
|
||||
component: () => import("../views/DatabaseMigration.vue"),
|
||||
},
|
||||
{
|
||||
path: "/new-activity",
|
||||
name: "new-activity",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger, safeStringify } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* Handles API errors with platform-specific logging and error processing.
|
||||
@@ -37,7 +37,8 @@ import { logger } from "../utils/logger";
|
||||
*/
|
||||
export const handleApiError = (error: AxiosError, endpoint: string) => {
|
||||
if (process.env.VITE_PLATFORM === "capacitor") {
|
||||
logger.error(`[Capacitor API Error] ${endpoint}:`, {
|
||||
const endpointStr = safeStringify(endpoint); // we've seen this as an object in deep links
|
||||
logger.error(`[Capacitor API Error] ${endpointStr}:`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
|
||||
@@ -27,18 +27,16 @@
|
||||
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
|
||||
*
|
||||
* Supported Routes:
|
||||
* - user-profile: View user profile
|
||||
* - project-details: View project details
|
||||
* - onboard-meeting-setup: Setup onboarding meeting
|
||||
* - invite-one-accept: Accept invitation
|
||||
* - contact-import: Import contacts
|
||||
* - confirm-gift: Confirm gift
|
||||
* - claim: View claim
|
||||
* - claim-cert: View claim certificate
|
||||
* - claim-add-raw: Add raw claim
|
||||
* - contact-edit: Edit contact
|
||||
* - contacts: View contacts
|
||||
* - claim-cert: View claim certificate
|
||||
* - confirm-gift
|
||||
* - contact-import: Import contacts
|
||||
* - did: View DID
|
||||
* - invite-one-accept: Accept invitation
|
||||
* - onboard-meeting-members
|
||||
* - project: View project details
|
||||
* - user-profile: View user profile
|
||||
*
|
||||
* @example
|
||||
* const handler = new DeepLinkHandler(router);
|
||||
@@ -81,18 +79,17 @@ export class DeepLinkHandler {
|
||||
string,
|
||||
{ name: string; paramKey?: string }
|
||||
> = {
|
||||
"user-profile": { name: "user-profile" },
|
||||
"project-details": { name: "project-details" },
|
||||
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
|
||||
"invite-one-accept": { name: "invite-one-accept" },
|
||||
"contact-import": { name: "contact-import" },
|
||||
"confirm-gift": { name: "confirm-gift" },
|
||||
// note that similar lists are in src/interfaces/deepLinks.ts
|
||||
claim: { name: "claim" },
|
||||
"claim-cert": { name: "claim-cert" },
|
||||
"claim-add-raw": { name: "claim-add-raw" },
|
||||
"contact-edit": { name: "contact-edit", paramKey: "did" },
|
||||
contacts: { name: "contacts" },
|
||||
"claim-cert": { name: "claim-cert" },
|
||||
"confirm-gift": { name: "confirm-gift" },
|
||||
"contact-import": { name: "contact-import", paramKey: "jwt" },
|
||||
did: { name: "did", paramKey: "did" },
|
||||
"invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" },
|
||||
"onboard-meeting-members": { name: "onboard-meeting-members" },
|
||||
project: { name: "project" },
|
||||
"user-profile": { name: "user-profile" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,7 +98,7 @@ export class DeepLinkHandler {
|
||||
*
|
||||
* @param url - The deep link URL to parse (format: scheme://path[?query])
|
||||
* @throws {DeepLinkError} If URL format is invalid
|
||||
* @returns Parsed URL components (path, params, query)
|
||||
* @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
|
||||
*/
|
||||
private parseDeepLink(url: string) {
|
||||
const parts = url.split("://");
|
||||
@@ -117,7 +114,16 @@ export class DeepLinkHandler {
|
||||
});
|
||||
|
||||
const [path, queryString] = parts[1].split("?");
|
||||
const [routePath, param] = path.split("/");
|
||||
const [routePath, ...pathParams] = path.split("/");
|
||||
// logger.info(
|
||||
// "[DeepLink] Debug:",
|
||||
// "Route Path:",
|
||||
// routePath,
|
||||
// "Path Params:",
|
||||
// pathParams,
|
||||
// "Query String:",
|
||||
// queryString,
|
||||
// );
|
||||
|
||||
// Validate route exists before proceeding
|
||||
if (!this.ROUTE_MAP[routePath]) {
|
||||
@@ -136,45 +142,14 @@ export class DeepLinkHandler {
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (param) {
|
||||
if (pathParams) {
|
||||
// Now we know routePath exists in ROUTE_MAP
|
||||
const routeConfig = this.ROUTE_MAP[routePath];
|
||||
params[routeConfig.paramKey ?? "id"] = param;
|
||||
params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
|
||||
}
|
||||
return { path: routePath, params, query };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes incoming deep links and routes them appropriately.
|
||||
* Handles validation, error handling, and routing to the correct view.
|
||||
*
|
||||
* @param url - The deep link URL to process
|
||||
* @throws {DeepLinkError} If URL processing fails
|
||||
*/
|
||||
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.
|
||||
* Validates route and parameters using Zod schemas before routing.
|
||||
@@ -245,6 +220,39 @@ export class DeepLinkHandler {
|
||||
code: "INVALID_PARAMETERS",
|
||||
message: (error as Error).message,
|
||||
details: error,
|
||||
params: params,
|
||||
query: query,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes incoming deep links and routes them appropriately.
|
||||
* Handles validation, error handling, and routing to the correct view.
|
||||
*
|
||||
* @param url - The deep link URL to process
|
||||
* @throws {DeepLinkError} If URL processing fails
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
1397
src/services/indexedDBMigrationService.ts
Normal file
1397
src/services/indexedDBMigrationService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,60 +1,133 @@
|
||||
/**
|
||||
* Manage database migrations as people upgrade their app over time
|
||||
*/
|
||||
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* Migration interface for database schema migrations
|
||||
*/
|
||||
interface Migration {
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
/**
|
||||
* Migration registry to store and manage database migrations
|
||||
*/
|
||||
class MigrationRegistry {
|
||||
private migrations: Migration[] = [];
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): MigrationService {
|
||||
if (!MigrationService.instance) {
|
||||
MigrationService.instance = new MigrationService();
|
||||
}
|
||||
return MigrationService.instance;
|
||||
}
|
||||
|
||||
registerMigration(migration: Migration) {
|
||||
/**
|
||||
* Register a migration with the registry
|
||||
*
|
||||
* @param migration - The migration to register
|
||||
*/
|
||||
registerMigration(migration: Migration): void {
|
||||
this.migrations.push(migration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param sqlExec - A function that executes a SQL statement and returns some update result
|
||||
* @param sqlQuery - A function that executes a SQL query and returns the result in some format
|
||||
* @param extractMigrationNames - A function that extracts the names (string array) from a "select name from migrations" query
|
||||
* Get all registered migrations
|
||||
*
|
||||
* @returns Array of registered migrations
|
||||
*/
|
||||
async runMigrations<T>(
|
||||
// note that this does not take parameters because the Capacitor SQLite 'execute' is different
|
||||
sqlExec: (sql: string) => Promise<unknown>,
|
||||
sqlQuery: (sql: string) => Promise<T>,
|
||||
extractMigrationNames: (result: T) => Set<string>,
|
||||
): Promise<void> {
|
||||
// Create migrations table if it doesn't exist
|
||||
await sqlExec(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
getMigrations(): Migration[] {
|
||||
return this.migrations;
|
||||
}
|
||||
|
||||
// Get list of executed migrations
|
||||
const result1: T = await sqlQuery("SELECT name FROM migrations;");
|
||||
const executedMigrations = extractMigrationNames(result1);
|
||||
|
||||
// Run pending migrations in order
|
||||
for (const migration of this.migrations) {
|
||||
if (!executedMigrations.has(migration.name)) {
|
||||
await sqlExec(migration.sql);
|
||||
|
||||
await sqlExec(
|
||||
`INSERT INTO migrations (name) VALUES ('${migration.name}')`,
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Clear all registered migrations
|
||||
*/
|
||||
clearMigrations(): void {
|
||||
this.migrations = [];
|
||||
}
|
||||
}
|
||||
|
||||
export default MigrationService.getInstance();
|
||||
// Create a singleton instance of the migration registry
|
||||
const migrationRegistry = new MigrationRegistry();
|
||||
|
||||
/**
|
||||
* Register a migration with the migration service
|
||||
*
|
||||
* This function is used by the migration system to register database
|
||||
* schema migrations that need to be applied to the database.
|
||||
*
|
||||
* @param migration - The migration to register
|
||||
*/
|
||||
export function registerMigration(migration: Migration): void {
|
||||
migrationRegistry.registerMigration(migration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all registered migrations against the database
|
||||
*
|
||||
* This function executes all registered migrations in order, checking
|
||||
* which ones have already been applied to avoid duplicate execution.
|
||||
* It creates a migrations table if it doesn't exist to track applied
|
||||
* migrations.
|
||||
*
|
||||
* @param sqlExec - Function to execute SQL statements
|
||||
* @param sqlQuery - Function to query SQL data
|
||||
* @param extractMigrationNames - Function to extract migration names from query results
|
||||
* @returns Promise that resolves when all migrations are complete
|
||||
*/
|
||||
export async function runMigrations<T>(
|
||||
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
|
||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
||||
extractMigrationNames: (result: T) => Set<string>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Create migrations table if it doesn't exist
|
||||
await sqlExec(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Get list of already applied migrations
|
||||
const appliedMigrationsResult = await sqlQuery(
|
||||
"SELECT name FROM migrations",
|
||||
);
|
||||
const appliedMigrations = extractMigrationNames(appliedMigrationsResult);
|
||||
|
||||
// Get all registered migrations
|
||||
const migrations = migrationRegistry.getMigrations();
|
||||
|
||||
if (migrations.length === 0) {
|
||||
logger.warn("[MigrationService] No migrations registered");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run each migration that hasn't been applied yet
|
||||
for (const migration of migrations) {
|
||||
if (appliedMigrations.has(migration.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute the migration SQL
|
||||
await sqlExec(migration.sql);
|
||||
|
||||
// Record that the migration was applied
|
||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
||||
migration.name,
|
||||
]);
|
||||
|
||||
logger.info(
|
||||
`[MigrationService] Successfully applied migration: ${migration.name}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[MigrationService] Failed to apply migration ${migration.name}:`,
|
||||
error,
|
||||
);
|
||||
throw new Error(`Migration ${migration.name} failed: ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[MigrationService] Migration process failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CameraSource,
|
||||
CameraDirection,
|
||||
} from "@capacitor/camera";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { Share } from "@capacitor/share";
|
||||
import {
|
||||
SQLiteConnection,
|
||||
@@ -247,7 +248,7 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
hasFileSystem: true,
|
||||
hasCamera: true,
|
||||
isMobile: true,
|
||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
isIOS: Capacitor.getPlatform() === "ios",
|
||||
hasFileDownload: false,
|
||||
needsFileHandlingInstructions: true,
|
||||
isNativeApp: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logToDb } from "../db/databaseUtil";
|
||||
|
||||
function safeStringify(obj: unknown) {
|
||||
export function safeStringify(obj: unknown) {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.stringify(obj, (_key, value) => {
|
||||
@@ -52,23 +52,18 @@ export const logger = {
|
||||
}
|
||||
},
|
||||
warn: (message: string, ...args: unknown[]) => {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" ||
|
||||
process.env.VITE_PLATFORM === "capacitor" ||
|
||||
process.env.VITE_PLATFORM === "electron"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message, ...args);
|
||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||
logToDb(message + argsString);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message, ...args);
|
||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||
logToDb(message + argsString);
|
||||
},
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
// Errors will always be logged
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message, ...args);
|
||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||
logToDb(message + argsString);
|
||||
const messageString = safeStringify(message);
|
||||
const argsString = args.length > 0 ? safeStringify(args) : "";
|
||||
logToDb(messageString + argsString);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -349,8 +349,9 @@
|
||||
</div>
|
||||
<div v-if="includeUserProfileLocation" class="mb-4 aspect-video">
|
||||
<p class="text-sm mb-2 text-slate-500">
|
||||
For your security, choose a location nearby but not exactly at your
|
||||
place.
|
||||
The location you choose will be shared with the world until you remove
|
||||
this checkbox. For your security, choose a location nearby but not
|
||||
exactly at your true location, like at your town center.
|
||||
</p>
|
||||
|
||||
<l-map
|
||||
@@ -435,11 +436,11 @@
|
||||
<p class="text-sm">
|
||||
You have done
|
||||
<b
|
||||
>{{ endorserLimits?.doneClaimsThisWeek || "?" }} claim{{
|
||||
>{{ endorserLimits?.doneClaimsThisWeek ?? "?" }} claim{{
|
||||
endorserLimits?.doneClaimsThisWeek === 1 ? "" : "s"
|
||||
}}</b
|
||||
>
|
||||
out of <b>{{ endorserLimits?.maxClaimsPerWeek || "?" }}</b> for this
|
||||
out of <b>{{ endorserLimits?.maxClaimsPerWeek ?? "?" }}</b> for this
|
||||
week. Your claims counter resets at
|
||||
<b class="whitespace-nowrap">{{
|
||||
readableDate(endorserLimits?.nextWeekBeginDateTime)
|
||||
@@ -449,14 +450,14 @@
|
||||
You have done
|
||||
<b
|
||||
>{{
|
||||
endorserLimits?.doneRegistrationsThisMonth || "?"
|
||||
endorserLimits?.doneRegistrationsThisMonth ?? "?"
|
||||
}}
|
||||
registration{{
|
||||
endorserLimits?.doneRegistrationsThisMonth === 1 ? "" : "s"
|
||||
}}</b
|
||||
>
|
||||
out of
|
||||
<b>{{ endorserLimits?.maxRegistrationsPerMonth || "?" }}</b> for this
|
||||
<b>{{ endorserLimits?.maxRegistrationsPerMonth ?? "?" }}</b> for this
|
||||
this month.
|
||||
<i>(You cannot register anyone on your first day.)</i>
|
||||
Your registration counter resets at
|
||||
@@ -467,11 +468,11 @@
|
||||
<p class="mt-3 text-sm">
|
||||
You have uploaded
|
||||
<b
|
||||
>{{ imageLimits?.doneImagesThisWeek || "?" }} image{{
|
||||
>{{ imageLimits?.doneImagesThisWeek ?? "?" }} image{{
|
||||
imageLimits?.doneImagesThisWeek === 1 ? "" : "s"
|
||||
}}</b
|
||||
>
|
||||
out of <b>{{ imageLimits?.maxImagesPerWeek || "?" }}</b> for this
|
||||
out of <b>{{ imageLimits?.maxImagesPerWeek ?? "?" }}</b> for this
|
||||
week. Your image counter resets at
|
||||
<b class="whitespace-nowrap">{{
|
||||
readableDate(imageLimits?.nextWeekBeginDateTime)
|
||||
|
||||
@@ -198,7 +198,7 @@ export default class ClaimAddRawView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -46,23 +46,35 @@
|
||||
</h2>
|
||||
<div class="flex justify-center w-full">
|
||||
<router-link
|
||||
v-if="veriClaim.id"
|
||||
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
|
||||
class="text-blue-500 mt-2"
|
||||
title="Printable Certificate"
|
||||
title="View Printable Certificate"
|
||||
>
|
||||
<font-awesome
|
||||
icon="square"
|
||||
class="text-white bg-yellow-500 p-1"
|
||||
/>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="veriClaim.id"
|
||||
class="text-blue-500 ml-2 mt-2"
|
||||
title="Copy Printable Certificate Link"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
'A link to the certificate page',
|
||||
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
|
||||
)
|
||||
"
|
||||
>
|
||||
<font-awesome icon="link" class="text-yellow-500 p-1" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- show link icon to copy this URL to the clipboard -->
|
||||
<div class="flex justify-end w-full">
|
||||
<button
|
||||
title="Copy Link"
|
||||
@click="
|
||||
copyToClipboard('A link to this page', window.location.href)
|
||||
"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>
|
||||
<font-awesome icon="link" class="text-slate-500" />
|
||||
</button>
|
||||
@@ -292,12 +304,17 @@
|
||||
<div class="text-sm">
|
||||
{{ didInfo(confirmerId) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
|
||||
<a :href="`/did/${confirmerId}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(confirmerId),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,12 +346,17 @@
|
||||
<div class="text-sm">
|
||||
{{ didInfo(confsVisibleTo) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
|
||||
<a :href="`/did/${confsVisibleTo}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(confsVisibleTo),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,7 +416,7 @@
|
||||
contacts can see more details:
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>click to copy this page info</a
|
||||
>
|
||||
and see if they can make an introduction. Someone is connected to
|
||||
@@ -417,7 +439,7 @@
|
||||
If you'd like an introduction,
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', windowLocation)"
|
||||
@click="copyToClipboard('A link to this page', windowDeepLink)"
|
||||
>share this page with them and ask if they'll tell you more about
|
||||
about the participants.</a
|
||||
>
|
||||
@@ -443,12 +465,17 @@
|
||||
<span>
|
||||
{{ didInfo(visDid) }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
|
||||
<a :href="`/did/${visDid}`" class="text-blue-500">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(visDid),
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-if="veriClaim.publicUrls?.[visDid]"
|
||||
>, found at <a
|
||||
@@ -530,7 +557,7 @@ import { useClipboard } from "@vueuse/core";
|
||||
import { GenericVerifiableCredential } from "../interfaces";
|
||||
import GiftedDialog from "../components/GiftedDialog.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { db } from "../db/index";
|
||||
import { logConsoleAndDb } from "../db/databaseUtil";
|
||||
@@ -577,8 +604,9 @@ export default class ClaimView extends Vue {
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
||||
windowLocation = window.location.href;
|
||||
windowDeepLink = window.location.href; // changed in the setup for deep linking
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
R = R;
|
||||
yaml = yaml;
|
||||
libsUtil = libsUtil;
|
||||
@@ -655,6 +683,7 @@ export default class ClaimView extends Vue {
|
||||
5000,
|
||||
);
|
||||
}
|
||||
this.windowDeepLink = `${APP_SERVER}/deep-link/claim/${claimId}`;
|
||||
|
||||
this.canShare = !!navigator.share;
|
||||
}
|
||||
@@ -925,7 +954,7 @@ export default class ClaimView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -990,11 +1019,11 @@ export default class ClaimView extends Vue {
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||
this.copyToClipboard("A link to this page", this.windowDeepLink);
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||
url: this.windowLocation,
|
||||
url: this.windowDeepLink,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -407,14 +407,14 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-2 ml-2">
|
||||
<a
|
||||
<router-link
|
||||
v-if="isRegistered"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
:href="urlForNewGive"
|
||||
:to="urlForNewGive"
|
||||
>
|
||||
<font-awesome icon="file-lines" />
|
||||
Record a Give Similar to the Original
|
||||
</a>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,7 +436,7 @@ import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
@@ -494,7 +494,7 @@ export default class ConfirmGiftView extends Vue {
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
||||
windowLocation = window.location.href;
|
||||
windowLocation = window.location.href; // this is changed to a deep link in the setup
|
||||
|
||||
R = R;
|
||||
yaml = yaml;
|
||||
@@ -566,6 +566,9 @@ export default class ConfirmGiftView extends Vue {
|
||||
}
|
||||
|
||||
const claimId = decodeURIComponent(pathParam);
|
||||
|
||||
this.windowLocation = APP_SERVER + "/deep-link/confirm-gift/" + claimId;
|
||||
|
||||
await this.loadClaim(claimId, this.activeDid);
|
||||
}
|
||||
|
||||
@@ -676,12 +679,12 @@ export default class ConfirmGiftView extends Vue {
|
||||
/**
|
||||
* Add participant (giver/recipient) name & URL info
|
||||
*/
|
||||
this.giverName = this.didInfo(this.giveDetails?.agentDid);
|
||||
if (this.giveDetails?.agentDid) {
|
||||
this.giverName = this.didInfo(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.recipientName = this.didInfo(this.giveDetails.recipientDid);
|
||||
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
|
||||
}
|
||||
|
||||
@@ -831,7 +834,7 @@ export default class ConfirmGiftView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -566,7 +566,7 @@ export default class ContactImportView extends Vue {
|
||||
this.checkingImports = true;
|
||||
|
||||
try {
|
||||
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
|
||||
const jwt: string = getContactJwtFromJwtUrl(jwtInput) || "";
|
||||
const payload = decodeEndorserJwt(jwt).payload;
|
||||
|
||||
if (Array.isArray(payload.contacts)) {
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Buffer } from "buffer/";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
@@ -117,14 +118,20 @@ import { db } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import { setVisibilityUtil } from "../libs/endorserServer";
|
||||
import {
|
||||
CONTACT_CSV_HEADER,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
generateEndorserJwtUrlForAccount,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
|
||||
import { retrieveAccountMetadata } from "../libs/util";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { parseJsonField } from "../db/databaseUtil";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface QRScanResult {
|
||||
rawValue?: string;
|
||||
@@ -142,7 +149,7 @@ interface IUserNameDialog {
|
||||
UserNameDialog,
|
||||
},
|
||||
})
|
||||
export default class ContactQRScan extends Vue {
|
||||
export default class ContactQRScanFull extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$router!: Router;
|
||||
|
||||
@@ -151,6 +158,8 @@ export default class ContactQRScan extends Vue {
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
givenName = "";
|
||||
isRegistered = false;
|
||||
profileImageUrl = "";
|
||||
qrValue = "";
|
||||
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
|
||||
|
||||
@@ -172,19 +181,22 @@ export default class ContactQRScan extends Vue {
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.givenName = settings.firstName || "";
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
this.profileImageUrl = settings.profileImageUrl || "";
|
||||
|
||||
const account = await retrieveAccountMetadata(this.activeDid);
|
||||
if (account) {
|
||||
const name =
|
||||
(settings.firstName || "") +
|
||||
(settings.lastName ? ` ${settings.lastName}` : "");
|
||||
this.qrValue = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
!!settings.isRegistered,
|
||||
name,
|
||||
settings.profileImageUrl || "",
|
||||
false,
|
||||
);
|
||||
const publicKeyBase64 = Buffer.from(
|
||||
account.publicKeyHex,
|
||||
"hex",
|
||||
).toString("base64");
|
||||
this.qrValue =
|
||||
CONTACT_CSV_HEADER +
|
||||
"\n" +
|
||||
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error initializing component:", {
|
||||
@@ -336,57 +348,69 @@ export default class ContactQRScan extends Vue {
|
||||
|
||||
logger.info("Processing QR code scan result:", rawValue);
|
||||
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
let contact: Contact;
|
||||
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
publicKeyBase64: contactInfo.publicKeyBase64 || "",
|
||||
seesMe: contactInfo.seesMe || false,
|
||||
registered: contactInfo.registered || false,
|
||||
};
|
||||
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
|
||||
const lines = rawValue.split(/\n/);
|
||||
contact = libsUtil.csvLineToContact(lines[1]);
|
||||
} else {
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
|
||||
title: "Error",
|
||||
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
company: contactInfo.company || "",
|
||||
title: contactInfo.title || "",
|
||||
notes: contactInfo.notes || "",
|
||||
};
|
||||
|
||||
// Add contact but keep scanning
|
||||
logger.info("Adding new contact to database:", {
|
||||
did: contact.did,
|
||||
@@ -468,7 +492,7 @@ export default class ContactQRScan extends Vue {
|
||||
title: "Contact Exists",
|
||||
text: "This contact has already been added to your list.",
|
||||
},
|
||||
3000,
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -568,9 +592,19 @@ export default class ContactQRScan extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
onCopyUrlToClipboard() {
|
||||
async onCopyUrlToClipboard() {
|
||||
const account = (await libsUtil.retrieveFullyDecryptedAccount(
|
||||
this.activeDid,
|
||||
)) as Account;
|
||||
const jwtUrl = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
this.isRegistered,
|
||||
this.givenName,
|
||||
this.profileImageUrl,
|
||||
true,
|
||||
);
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.copy(jwtUrl)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError } from "axios";
|
||||
import { Buffer } from "buffer/";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
@@ -174,17 +175,20 @@ import * as databaseUtil from "../db/databaseUtil";
|
||||
import { parseJsonField } from "../db/databaseUtil";
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import {
|
||||
CONTACT_CSV_HEADER,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
generateEndorserJwtUrlForAccount,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
|
||||
import { retrieveAccountMetadata } from "../libs/util";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { Router } from "vue-router";
|
||||
import { logger } from "../utils/logger";
|
||||
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
|
||||
import { CameraState } from "@/services/QRScanner/types";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface QRScanResult {
|
||||
rawValue?: string;
|
||||
@@ -214,6 +218,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
isRegistered = false;
|
||||
qrValue = "";
|
||||
isScanning = false;
|
||||
profileImageUrl = "";
|
||||
error: string | null = null;
|
||||
|
||||
// QR Scanner properties
|
||||
@@ -251,19 +256,21 @@ export default class ContactQRScanShow extends Vue {
|
||||
this.hideRegisterPromptOnNewContact =
|
||||
!!settings.hideRegisterPromptOnNewContact;
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
this.profileImageUrl = settings.profileImageUrl || "";
|
||||
|
||||
const account = await retrieveAccountMetadata(this.activeDid);
|
||||
const account = await libsUtil.retrieveAccountMetadata(this.activeDid);
|
||||
if (account) {
|
||||
const name =
|
||||
(settings.firstName || "") +
|
||||
(settings.lastName ? ` ${settings.lastName}` : "");
|
||||
this.qrValue = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
!!settings.isRegistered,
|
||||
name,
|
||||
settings.profileImageUrl || "",
|
||||
false,
|
||||
);
|
||||
const publicKeyBase64 = Buffer.from(
|
||||
account.publicKeyHex,
|
||||
"hex",
|
||||
).toString("base64");
|
||||
this.qrValue =
|
||||
CONTACT_CSV_HEADER +
|
||||
"\n" +
|
||||
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error initializing component:", {
|
||||
@@ -274,7 +281,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Initialization Error",
|
||||
text: "Failed to initialize QR scanner. Please try again.",
|
||||
text: "Failed to initialize QR renderer or scanner. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -461,53 +468,68 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
logger.info("Processing QR code scan result:", rawValue);
|
||||
|
||||
// Extract JWT
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
let contact: Contact;
|
||||
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
|
||||
const jwt = getContactJwtFromJwtUrl(rawValue);
|
||||
if (!jwt) {
|
||||
logger.warn("Invalid QR code format - no JWT found in URL");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
|
||||
// Process JWT and contact info
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
publicKeyBase64: contactInfo.publicKeyBase64 || "",
|
||||
seesMe: contactInfo.seesMe || false,
|
||||
registered: contactInfo.registered || false,
|
||||
};
|
||||
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
|
||||
const lines = rawValue.split(/\n/);
|
||||
contact = libsUtil.csvLineToContact(lines[1]);
|
||||
} else {
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid QR Code",
|
||||
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
|
||||
title: "Error",
|
||||
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process JWT and contact info
|
||||
logger.info("Decoding JWT payload from QR code");
|
||||
const decodedJwt = await decodeEndorserJwt(jwt);
|
||||
if (!decodedJwt?.payload?.own) {
|
||||
logger.warn("Invalid JWT payload - missing 'own' field");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact Info",
|
||||
text: "The contact information is incomplete or invalid.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Invalid Contact",
|
||||
text: "The contact DID is missing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
notes: contactInfo.notes || "",
|
||||
};
|
||||
|
||||
// Add contact but keep scanning
|
||||
logger.info("Adding new contact to database:", {
|
||||
did: contact.did,
|
||||
@@ -649,12 +671,20 @@ export default class ContactQRScanShow extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
onCopyUrlToClipboard() {
|
||||
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
|
||||
async onCopyUrlToClipboard() {
|
||||
const account = (await libsUtil.retrieveFullyDecryptedAccount(
|
||||
this.activeDid,
|
||||
)) as Account;
|
||||
const jwtUrl = await generateEndorserJwtUrlForAccount(
|
||||
account,
|
||||
this.isRegistered,
|
||||
this.givenName,
|
||||
this.profileImageUrl,
|
||||
true,
|
||||
);
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.copy(jwtUrl)
|
||||
.then(() => {
|
||||
// console.log("Contact URL:", this.qrValue);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -772,7 +802,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
title: "Contact Exists",
|
||||
text: "This contact has already been added to your list.",
|
||||
},
|
||||
3000,
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<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 px-3 py-1.5 rounded-md"
|
||||
:class="showGiveAmountsClassNames()"
|
||||
@click="toggleShowGiveTotals()"
|
||||
@@ -142,7 +141,6 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
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 px-3 py-1.5 rounded-md"
|
||||
@click="toggleShowContactAmounts()"
|
||||
>
|
||||
@@ -493,7 +491,7 @@ export default class ContactsView extends Vue {
|
||||
private async processContactJwt() {
|
||||
// handle a contact sent via URL
|
||||
//
|
||||
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
|
||||
// For external links, use /deep-link/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.query["contactJwt"] as string;
|
||||
if (importedContactJwt) {
|
||||
@@ -619,7 +617,7 @@ export default class ContactsView extends Vue {
|
||||
title: "Error with Invite",
|
||||
text: message,
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
|
||||
@@ -935,45 +933,9 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
|
||||
private async addContactFromEndorserMobileLine(
|
||||
line: string,
|
||||
lineRaw: string,
|
||||
): Promise<IndexableType> {
|
||||
// Note that Endorser Mobile puts name first, then did, etc.
|
||||
let name = line;
|
||||
let did = "";
|
||||
let publicKeyInput, seesMe, registered;
|
||||
const commaPos1 = line.indexOf(",");
|
||||
if (commaPos1 > -1) {
|
||||
name = line.substring(0, commaPos1).trim();
|
||||
did = line.substring(commaPos1 + 1).trim();
|
||||
const commaPos2 = line.indexOf(",", commaPos1 + 1);
|
||||
if (commaPos2 > -1) {
|
||||
did = line.substring(commaPos1 + 1, commaPos2).trim();
|
||||
publicKeyInput = line.substring(commaPos2 + 1).trim();
|
||||
const commaPos3 = line.indexOf(",", commaPos2 + 1);
|
||||
if (commaPos3 > -1) {
|
||||
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
|
||||
seesMe = line.substring(commaPos3 + 1).trim() == "true";
|
||||
const commaPos4 = line.indexOf(",", commaPos3 + 1);
|
||||
if (commaPos4 > -1) {
|
||||
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
|
||||
registered = line.substring(commaPos4 + 1).trim() == "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// help with potential mistakes while this sharing requires copy-and-paste
|
||||
let publicKeyBase64 = publicKeyInput;
|
||||
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
|
||||
// it must be all hex (compressed public key), so convert
|
||||
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
||||
}
|
||||
const newContact = {
|
||||
did,
|
||||
name,
|
||||
publicKeyBase64,
|
||||
seesMe,
|
||||
registered,
|
||||
};
|
||||
const newContact = libsUtil.csvLineToContact(lineRaw);
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const { sql, params } = databaseUtil.generateInsertStatement(
|
||||
newContact as unknown as Record<string, unknown>,
|
||||
@@ -1160,7 +1122,7 @@ export default class ContactsView extends Vue {
|
||||
(regResult.error as string) ||
|
||||
"Something went wrong during registration.",
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1194,7 +1156,7 @@ export default class ContactsView extends Vue {
|
||||
title: "Registration Error",
|
||||
text: userMessage,
|
||||
},
|
||||
5000,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1215,7 +1177,6 @@ export default class ContactsView extends Vue {
|
||||
);
|
||||
if (result.success) {
|
||||
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
|
||||
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
|
||||
if (showSuccessAlert) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -1431,14 +1392,11 @@ export default class ContactsView extends Vue {
|
||||
}
|
||||
return contact;
|
||||
});
|
||||
// console.log(
|
||||
// "Array of selected contacts:",
|
||||
// JSON.stringify(selectedContacts),
|
||||
// );
|
||||
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
|
||||
contacts: selectedContacts,
|
||||
});
|
||||
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt;
|
||||
const contactsJwtUrl =
|
||||
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
|
||||
useClipboard()
|
||||
.copy(contactsJwtUrl)
|
||||
.then(() => {
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
@click="confirmSetVisibility(contactFromDid, false)"
|
||||
>
|
||||
<font-awesome icon="eye" class="fa-fw" />
|
||||
<font-awesome icon="arrow-up" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="
|
||||
@@ -87,6 +88,32 @@
|
||||
@click="confirmSetVisibility(contactFromDid, true)"
|
||||
>
|
||||
<font-awesome icon="eye-slash" class="fa-fw" />
|
||||
<font-awesome icon="arrow-up" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="
|
||||
contactFromDid?.iViewContent &&
|
||||
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="I view their content"
|
||||
@click="confirmViewContent(contactFromDid, false)"
|
||||
>
|
||||
<font-awesome icon="eye" class="fa-fw" />
|
||||
<font-awesome icon="arrow-down" class="fa-fw" />
|
||||
</button>
|
||||
<button
|
||||
v-else-if="
|
||||
!contactFromDid?.iViewContent &&
|
||||
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="I do not view their content"
|
||||
@click="confirmViewContent(contactFromDid, true)"
|
||||
>
|
||||
<font-awesome icon="eye-slash" class="fa-fw" />
|
||||
<font-awesome icon="arrow-down" class="fa-fw" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -825,9 +852,9 @@ export default class DIDView extends Vue {
|
||||
title: "Visibility Refreshed",
|
||||
text:
|
||||
libsUtil.nameForContact(contact, true) +
|
||||
" can " +
|
||||
(visibility ? "" : "not ") +
|
||||
"see your activity.",
|
||||
" can" +
|
||||
(visibility ? "" : " not") +
|
||||
" see your activity.",
|
||||
},
|
||||
3000,
|
||||
);
|
||||
@@ -857,6 +884,64 @@ export default class DIDView extends Vue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm whether the user want to see/hide the other's content, then execute it
|
||||
*
|
||||
* @param contact Contact content to show/hide from user
|
||||
* @param view whether user wants to view this contact
|
||||
*/
|
||||
async confirmViewContent(contact: Contact, view: boolean) {
|
||||
const contentVisibilityPrompt = view
|
||||
? "Are you sure you want to see their content?"
|
||||
: "Are you sure you want to hide their content from you?";
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Set Content Visibility",
|
||||
text: contentVisibilityPrompt,
|
||||
onYes: async () => {
|
||||
const success = await this.setViewContent(contact, view);
|
||||
if (success) {
|
||||
contact.iViewContent = view; // see visibility note about not working inside setVisibility
|
||||
}
|
||||
},
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates contact content visibility for this device
|
||||
*
|
||||
* @param contact - Contact to update content visibility for
|
||||
* @param visibility - New content visibility state
|
||||
* @returns Boolean indicating success
|
||||
*/
|
||||
async setViewContent(contact: Contact, visibility: boolean) {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec(
|
||||
"UPDATE contacts SET iViewContent = ? WHERE did = ?",
|
||||
[visibility, contact.did],
|
||||
);
|
||||
if (USE_DEXIE_DB) {
|
||||
db.contacts.update(contact.did, { iViewContent: visibility });
|
||||
}
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Visibility Set",
|
||||
text:
|
||||
"You will" +
|
||||
(visibility ? "" : " not") +
|
||||
` see ${contact.name}'s activity.`,
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
1492
src/views/DatabaseMigration.vue
Normal file
1492
src/views/DatabaseMigration.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -66,9 +66,14 @@ const formattedPath = computed(() => {
|
||||
const path = originalPath.value.replace(/^\/+/, "");
|
||||
|
||||
// Log for debugging
|
||||
logger.log("Original Path:", originalPath.value);
|
||||
logger.log("Route Params:", route.params);
|
||||
logger.log("Route Query:", route.query);
|
||||
logger.log(
|
||||
"[DeepLinkError] Original Path:",
|
||||
originalPath.value,
|
||||
"Route Params:",
|
||||
route.params,
|
||||
"Route Query:",
|
||||
route.query,
|
||||
);
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
227
src/views/DeepLinkRedirectView.vue
Normal file
227
src/views/DeepLinkRedirectView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="relative w-[100vw] h-[100vh]">
|
||||
<div
|
||||
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl text-center font-semibold relative mb-4">
|
||||
Redirecting to Time Safari
|
||||
</h1>
|
||||
|
||||
<div v-if="destinationUrl" class="space-y-4">
|
||||
<!-- Platform-specific messaging -->
|
||||
<div class="text-center text-gray-600 mb-4">
|
||||
<p v-if="isMobile">
|
||||
{{
|
||||
isIOS
|
||||
? "Opening Time Safari app on your iPhone..."
|
||||
: "Opening Time Safari app on your Android device..."
|
||||
}}
|
||||
</p>
|
||||
<p v-else>Opening Time Safari app...</p>
|
||||
<p class="text-sm mt-2">
|
||||
<span v-if="isMobile"
|
||||
>If the app doesn't open automatically, use one of these
|
||||
options:</span
|
||||
>
|
||||
<span v-else>Choose how you'd like to open this link:</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Deep Link Button -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="deepLinkUrl || '#'"
|
||||
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
@click="handleDeepLinkClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Time Safari App</span>
|
||||
<span v-else>Try Opening in Time Safari App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Web Fallback Link -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
:href="webUrl || '#'"
|
||||
target="_blank"
|
||||
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
|
||||
@click="handleWebFallbackClick"
|
||||
>
|
||||
<span v-if="isMobile">Open in Web Browser Instead</span>
|
||||
<span v-else>Open in Web Browser</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Manual Instructions -->
|
||||
<div class="text-center text-sm text-gray-500 mt-4">
|
||||
<p v-if="isMobile">
|
||||
Or manually open:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{
|
||||
deepLinkUrl
|
||||
}}</code>
|
||||
</p>
|
||||
<p v-else>
|
||||
If you have the Time Safari app installed, you can also copy this
|
||||
link:
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{
|
||||
deepLinkUrl
|
||||
}}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform info for debugging -->
|
||||
<div
|
||||
v-if="isDevelopment"
|
||||
class="text-center text-xs text-gray-400 mt-4"
|
||||
>
|
||||
<p>
|
||||
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
|
||||
</p>
|
||||
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
|
||||
{{ pageError }}
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-gray-600">
|
||||
<p>Processing redirect...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
|
||||
import { APP_SERVER } from "@/constants/app";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { errorStringForLog } from "@/libs/endorserServer";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
|
||||
@Component({})
|
||||
export default class DeepLinkRedirectView extends Vue {
|
||||
$router!: Router;
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
pageError: string | null = null;
|
||||
destinationUrl: string | null = null; // full path after "/deep-link/"
|
||||
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
|
||||
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
|
||||
isDevelopment: boolean = false;
|
||||
userAgent: string = "";
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
mounted() {
|
||||
// Get the path from the route parameter (catch-all parameter)
|
||||
const pathParam = this.$route.params.path;
|
||||
|
||||
// If pathParam is an array (catch-all parameter), join it
|
||||
const fullPath = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
|
||||
|
||||
// Get query parameters from the route
|
||||
const queryParams = this.$route.query;
|
||||
|
||||
// Build query string if there are query parameters
|
||||
let queryString = "";
|
||||
if (Object.keys(queryParams).length > 0) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
const stringValue = Array.isArray(value) ? value[0] : value;
|
||||
if (stringValue !== null && stringValue !== undefined) {
|
||||
searchParams.append(key, stringValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
queryString = "?" + searchParams.toString();
|
||||
}
|
||||
|
||||
// Combine path with query parameters
|
||||
const fullPathWithQuery = fullPath + queryString;
|
||||
|
||||
this.destinationUrl = fullPathWithQuery;
|
||||
this.deepLinkUrl = `timesafari://${fullPathWithQuery}`;
|
||||
this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`;
|
||||
|
||||
this.isDevelopment = process.env.NODE_ENV !== "production";
|
||||
this.userAgent = navigator.userAgent;
|
||||
|
||||
this.openDeepLink();
|
||||
}
|
||||
|
||||
private openDeepLink() {
|
||||
if (!this.deepLinkUrl || !this.webUrl) {
|
||||
this.pageError =
|
||||
"No deep link was provided. Check the URL and try again.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For mobile, try the deep link URL; for desktop, use the web URL
|
||||
const redirectUrl = this.isMobile ? this.deepLinkUrl : this.webUrl;
|
||||
|
||||
// Method 1: Try window.location.href (works on most browsers)
|
||||
window.location.href = redirectUrl;
|
||||
|
||||
// Method 2: Fallback - create and click a link element
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const link = document.createElement("a");
|
||||
link.href = redirectUrl;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Fallback deep link failed: " + errorStringForLog(error),
|
||||
);
|
||||
this.pageError =
|
||||
"Redirecting to the Time Safari app failed. Please use a manual option below.";
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
logger.error("Deep link redirect failed: " + errorStringForLog(error));
|
||||
this.pageError =
|
||||
"Unable to open the Time Safari app. Please use a manual option below.";
|
||||
}
|
||||
}
|
||||
|
||||
private handleDeepLinkClick(event: Event) {
|
||||
if (!this.deepLinkUrl) return;
|
||||
|
||||
// Prevent default to handle the click manually
|
||||
event.preventDefault();
|
||||
|
||||
this.openDeepLink();
|
||||
}
|
||||
|
||||
private handleWebFallbackClick(event: Event) {
|
||||
if (!this.webUrl) return;
|
||||
|
||||
// Get platform capabilities
|
||||
const capabilities = this.platformService.getCapabilities();
|
||||
|
||||
// For mobile, try to open in a new tab/window
|
||||
if (capabilities.isMobile) {
|
||||
event.preventDefault();
|
||||
window.open(this.webUrl, "_blank");
|
||||
}
|
||||
// For desktop, let the default behavior happen (opens in same tab)
|
||||
}
|
||||
|
||||
// Computed properties for template
|
||||
get isMobile(): boolean {
|
||||
return this.platformService.getCapabilities().isMobile;
|
||||
}
|
||||
|
||||
get isIOS(): boolean {
|
||||
return this.platformService.getCapabilities().isIOS;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -523,9 +523,7 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
logger.error("Error with search all:", e);
|
||||
// this sometimes gives different information
|
||||
logger.error("Error with search all (error added): " + e);
|
||||
logger.error("Error with search all: " + errorStringForLog(e));
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -617,7 +615,7 @@ export default class DiscoverView extends Vue {
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
logger.error("Error with search local:", e);
|
||||
logger.error("Error with search local: " + errorStringForLog(e));
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -788,7 +786,7 @@ export default class DiscoverView extends Vue {
|
||||
const route = {
|
||||
path: this.isProjectsActive
|
||||
? "/project/" + encodeURIComponent(id)
|
||||
: "/userProfile/" + encodeURIComponent(id),
|
||||
: "/user-profile/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
@@ -826,7 +826,7 @@ export default class GiftedDetails extends Vue {
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
const errorMessage = result.error;
|
||||
logger.error("Error with give creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
@@ -899,19 +899,6 @@ export default class GiftedDetails extends Vue {
|
||||
|
||||
// Helper functions for readability
|
||||
|
||||
/**
|
||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
||||
* @returns best guess at an error message
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getGiveCreationErrorMessage(result: any) {
|
||||
return (
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
<!-- eslint-disable prettier/prettier max-len -->
|
||||
<div>
|
||||
<p>
|
||||
This app focuses on gifts & gratitude, using them to build cool things together with your network.
|
||||
This app focuses on raw gratitude, using it to build cool things together with your network.
|
||||
</p>
|
||||
|
||||
<p class="ml-4">
|
||||
If you'd like to see the page-by-page help,
|
||||
If you'd like to see the page-by-page help again,
|
||||
<span
|
||||
class="text-blue-500 cursor-pointer"
|
||||
@click="unsetFinishedOnboarding()"
|
||||
@@ -37,14 +37,16 @@
|
||||
|
||||
<h2 class="text-xl font-semibold">What is the idea here?</h2>
|
||||
<p>
|
||||
We are building networks of people who want to grow good society from the ground up, using modern
|
||||
technology that connects people peer-to-peer.
|
||||
First of all, let's showcase gratitude: see what people have given, and recognize
|
||||
gifts you've seen. This is done in a way that leaves a permanent record -- one that
|
||||
came from you, and one that the recipient can prove it was for them. This can be
|
||||
personally gratifying, but it extends to broader work: volunteers get
|
||||
confirmation of activity, and they can selectively show off their contributions
|
||||
and network.
|
||||
We are building networks of people who want to grow good society from the ground up, using
|
||||
modern technology that connects people peer-to-peer.
|
||||
First of all, let's showcase gratitude: see what people have given, and recognize gifts
|
||||
you've seen. This is done in a way that leaves a permanent record -- one that provably
|
||||
came from you, and one that the recipient can prove they were mentioned.
|
||||
This can be personally gratifying, but it extends to broader work: volunteers get
|
||||
confirmation of activity, and they can selectively show off their contributions and
|
||||
network.
|
||||
This is a way to build trust and reputation. It's a way to build a network of people who
|
||||
are willing to help each other.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
With this, you highlight giving and you also offer help --
|
||||
@@ -555,9 +557,6 @@
|
||||
initiative.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement.
|
||||
</h2>
|
||||
@@ -567,6 +566,28 @@
|
||||
>info@TimeSafari.app</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<div v-if="Capacitor.isNativePlatform()">
|
||||
<h2 class="text-xl font-semibold">
|
||||
Do I have the latest version?
|
||||
</h2>
|
||||
<p v-if="Capacitor.getPlatform() === 'ios'">
|
||||
<a href="https://apps.apple.com/us/app/time-safari/id6742664907" target="_blank" class="text-blue-500">
|
||||
Check the App Store.
|
||||
</a>
|
||||
</p>
|
||||
<p v-else-if="Capacitor.getPlatform() === 'android'">
|
||||
<a href="https://timesafari.app/app.apk" target="_blank" class="text-blue-500">
|
||||
Download the latest APK to see.
|
||||
</a>
|
||||
</p>
|
||||
<p v-else>
|
||||
Sorry, your platform of '{{ Capacitor.getPlatform() }}' is not recognized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- eslint enable -->
|
||||
</section>
|
||||
@@ -603,6 +624,7 @@ export default class HelpView extends Vue {
|
||||
showVerifiable = false;
|
||||
|
||||
APP_SERVER = APP_SERVER;
|
||||
Capacitor = Capacitor;
|
||||
|
||||
// Ideally, we put no functionality in here, especially in the setup,
|
||||
// because we never want this page to have a chance of throwing an error.
|
||||
|
||||
@@ -12,6 +12,7 @@ Raymer * @version 1.0.0 */
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
|
||||
{{ AppString.APP_NAME }}
|
||||
<span class="text-xs text-gray-500">{{ package.version }}</span>
|
||||
</h1>
|
||||
|
||||
<OnboardingDialog ref="onboardingDialog" />
|
||||
@@ -106,12 +107,12 @@ Raymer * @version 1.0.0 */
|
||||
</button>
|
||||
</div>
|
||||
<UserNameDialog ref="userNameDialog" />
|
||||
<div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
|
||||
<div class="flex justify-end w-full">
|
||||
<router-link
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-right 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"
|
||||
>
|
||||
See all your options first
|
||||
See advanced options
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,6 +326,7 @@ import * as serverUtil from "../libs/endorserServer";
|
||||
import { logger } from "../utils/logger";
|
||||
import { GiveRecordWithContactInfo } from "../interfaces/give";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import * as Package from "../../package.json";
|
||||
|
||||
interface Claim {
|
||||
claim?: Claim; // For nested claims in Verifiable Credentials
|
||||
@@ -415,11 +417,13 @@ export default class HomeView extends Vue {
|
||||
|
||||
AppString = AppString;
|
||||
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
|
||||
package = Package;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
blockedContactDids: Array<string> = [];
|
||||
feedData: GiveRecordWithContactInfo[] = [];
|
||||
feedPreviousOldestId?: string;
|
||||
feedLastViewedClaimId?: string;
|
||||
@@ -492,7 +496,6 @@ export default class HomeView extends Vue {
|
||||
// Retrieve DIDs with better error handling
|
||||
try {
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`);
|
||||
} catch (error) {
|
||||
logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true);
|
||||
throw new Error(
|
||||
@@ -525,9 +528,6 @@ export default class HomeView extends Vue {
|
||||
if (USE_DEXIE_DB) {
|
||||
settings = await retrieveSettingsForActiveAccount();
|
||||
}
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Failed to retrieve settings: ${error}`,
|
||||
@@ -544,25 +544,14 @@ export default class HomeView extends Vue {
|
||||
|
||||
// Load contacts with graceful fallback
|
||||
try {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const dbContacts = await platformService.dbQuery(
|
||||
"SELECT * FROM contacts",
|
||||
);
|
||||
this.allContacts = databaseUtil.mapQueryResultToValues(
|
||||
dbContacts,
|
||||
) as Contact[];
|
||||
if (USE_DEXIE_DB) {
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
}
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
|
||||
);
|
||||
this.loadContacts();
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Failed to retrieve contacts: ${error}`,
|
||||
true,
|
||||
);
|
||||
this.allContacts = []; // Ensure we have a valid empty array
|
||||
this.blockedContactDids = [];
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -614,9 +603,6 @@ export default class HomeView extends Vue {
|
||||
});
|
||||
}
|
||||
this.isRegistered = true;
|
||||
logConsoleAndDb(
|
||||
`[HomeView] User ${this.activeDid} is now registered`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
@@ -658,11 +644,6 @@ export default class HomeView extends Vue {
|
||||
this.newOffersToUserHitLimit = offersToUser.hitLimit;
|
||||
this.numNewOffersToUserProjects = offersToProjects.data.length;
|
||||
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
|
||||
|
||||
logConsoleAndDb(
|
||||
`[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` +
|
||||
`${this.numNewOffersToUserProjects} project offers`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
@@ -734,6 +715,9 @@ export default class HomeView extends Vue {
|
||||
if (USE_DEXIE_DB) {
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
}
|
||||
this.blockedContactDids = this.allContacts
|
||||
.filter((c) => !c.iViewContent)
|
||||
.map((c) => c.did);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1001,6 +985,7 @@ export default class HomeView extends Vue {
|
||||
);
|
||||
if (results.data.length > 0) {
|
||||
endOfResults = false;
|
||||
// gather any contacts that user has blocked from view
|
||||
await this.processFeedResults(results.data);
|
||||
await this.updateFeedLastViewedId(results.data);
|
||||
}
|
||||
@@ -1188,7 +1173,7 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if record should be included based on filters
|
||||
* Checks if record should be included based on filters & preferences
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
@@ -1214,6 +1199,10 @@ export default class HomeView extends Vue {
|
||||
record: GiveSummaryRecord,
|
||||
fulfillsPlan?: FulfillsPlan,
|
||||
): boolean {
|
||||
if (this.blockedContactDids.includes(record.issuerDid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.isAnyFeedFilterOn) {
|
||||
return true;
|
||||
}
|
||||
@@ -1832,7 +1821,7 @@ export default class HomeView extends Vue {
|
||||
this.axios,
|
||||
);
|
||||
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<div v-if="numAccounts == 1" class="mt-4">
|
||||
<input v-model="shouldErase" type="checkbox" class="mr-2" />
|
||||
<label>Erase the previous identifier.</label>
|
||||
<label>Erase previous identifiers.</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isNotProdServer()" class="mt-4 text-blue-500">
|
||||
@@ -88,17 +88,9 @@ import { Router } from "vue-router";
|
||||
|
||||
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import {
|
||||
accountsDBPromise,
|
||||
retrieveSettingsForActiveAccount,
|
||||
} from "../db/index";
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
} from "../libs/crypto";
|
||||
import { retrieveAccountCount, saveNewIdentity } from "../libs/util";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
|
||||
import { retrieveAccountCount, importFromMnemonic } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component({
|
||||
@@ -115,12 +107,9 @@ export default class ImportAccountView extends Vue {
|
||||
$router!: Router;
|
||||
|
||||
apiServer = "";
|
||||
address = "";
|
||||
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
||||
mnemonic = "";
|
||||
numAccounts = 0;
|
||||
privateHex = "";
|
||||
publicHex = "";
|
||||
showAdvanced = false;
|
||||
shouldErase = false;
|
||||
|
||||
@@ -143,33 +132,16 @@ export default class ImportAccountView extends Vue {
|
||||
}
|
||||
|
||||
public async fromMnemonic() {
|
||||
const mne: string = this.mnemonic.trim().toLowerCase();
|
||||
try {
|
||||
[this.address, this.privateHex, this.publicHex] = deriveAddress(
|
||||
mne,
|
||||
await importFromMnemonic(
|
||||
this.mnemonic,
|
||||
this.derivationPath,
|
||||
this.shouldErase,
|
||||
);
|
||||
|
||||
const newId = newIdentifier(
|
||||
this.address,
|
||||
this.publicHex,
|
||||
this.privateHex,
|
||||
this.derivationPath,
|
||||
);
|
||||
|
||||
const accountsDB = await accountsDBPromise;
|
||||
if (this.shouldErase) {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
await platformService.dbExec("DELETE FROM accounts");
|
||||
if (USE_DEXIE_DB) {
|
||||
await accountsDB.accounts.clear();
|
||||
}
|
||||
}
|
||||
await saveNewIdentity(newId, mne, this.derivationPath);
|
||||
this.$router.push({ name: "account" });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
logger.error("Error saving mnemonic & updating settings:", err);
|
||||
logger.error("Error importing from mnemonic:", err);
|
||||
if (err == "Error: invalid mnemonic") {
|
||||
this.$notify(
|
||||
{
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<span
|
||||
v-else
|
||||
class="text-center text-slate-500 cursor-pointer"
|
||||
:title="inviteLink(invite.jwt)"
|
||||
:title="invite.inviteIdentifier"
|
||||
@click="
|
||||
showInvite(
|
||||
invite.inviteIdentifier,
|
||||
@@ -241,7 +241,7 @@ export default class InviteOneView extends Vue {
|
||||
}
|
||||
|
||||
inviteLink(jwt: string): string {
|
||||
return APP_SERVER + "/invite-one-accept/" + jwt;
|
||||
return APP_SERVER + "/deep-link/invite-one-accept/" + jwt;
|
||||
}
|
||||
|
||||
copyInviteAndNotify(inviteId: string, jwt: string) {
|
||||
@@ -324,7 +324,7 @@ export default class InviteOneView extends Vue {
|
||||
);
|
||||
await axios.post(
|
||||
this.apiServer + "/api/userUtil/invite",
|
||||
{ inviteIdentifier, inviteJwt, notes, expiresAt },
|
||||
{ inviteJwt, notes, expiresAt },
|
||||
{ headers },
|
||||
);
|
||||
const newInvite = {
|
||||
|
||||
@@ -720,7 +720,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
|
||||
onboardMeetingMembersLink(): string {
|
||||
if (this.currentMeeting) {
|
||||
return `${APP_SERVER}/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
|
||||
return `${APP_SERVER}/deep-link/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
|
||||
this.currentMeeting?.password || "",
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
>
|
||||
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||
</button>
|
||||
<button title="Copy Link to Project" @click="onCopyLinkClick()">
|
||||
<font-awesome
|
||||
icon="link"
|
||||
class="text-sm text-slate-500 ml-2 mb-1"
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,16 +58,28 @@
|
||||
icon="user"
|
||||
class="fa-fw text-slate-400"
|
||||
></font-awesome>
|
||||
{{ issuerInfoObject?.displayName }}
|
||||
<span v-if="!serverUtil.isEmptyOrHiddenDid(issuer)">
|
||||
<a :href="`/did/${issuer}`" class="text-blue-500">
|
||||
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
|
||||
{{ issuerInfoObject?.displayName }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!serverUtil.isHiddenDid(issuer)"
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(issuer),
|
||||
}"
|
||||
class="text-blue-500 ml-1"
|
||||
title="See more about this person"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="fa-fw"
|
||||
/>
|
||||
</a>
|
||||
</router-link>
|
||||
</span>
|
||||
<span v-else-if="serverUtil.isHiddenDid(issuer)">
|
||||
<span v-if="serverUtil.isHiddenDid(issuer)" class="ml-1">
|
||||
<font-awesome
|
||||
icon="info-circle"
|
||||
class="fa-fw text-blue-500 cursor-pointer"
|
||||
@@ -105,7 +123,7 @@
|
||||
class="fa-fw text-slate-400"
|
||||
></font-awesome>
|
||||
<a
|
||||
:href="addScheme(url)"
|
||||
:href="ensureScheme(url)"
|
||||
target="_blank"
|
||||
class="underline text-blue-500"
|
||||
>
|
||||
@@ -577,7 +595,7 @@ import TopMessage from "../components/TopMessage.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
|
||||
import * as databaseUtil from "../db/databaseUtil";
|
||||
import {
|
||||
db,
|
||||
@@ -591,6 +609,7 @@ import { retrieveAccountDids } from "../libs/util";
|
||||
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
/**
|
||||
* Project View Component
|
||||
* @author Matthew Raymer
|
||||
@@ -787,6 +806,28 @@ export default class ProjectViewView extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
onCopyLinkClick() {
|
||||
const shortestProjectId = this.projectId.startsWith(
|
||||
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
|
||||
)
|
||||
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
|
||||
: this.projectId;
|
||||
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
|
||||
useClipboard()
|
||||
.copy(deepLink)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: "A link to this project was copied to the clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
expandText() {
|
||||
this.expanded = true;
|
||||
@@ -1281,7 +1322,7 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
|
||||
// return an HTTPS URL if it's not a global URL
|
||||
addScheme(url: string) {
|
||||
ensureScheme(url: string) {
|
||||
if (!libsUtil.isGlobalUri(url)) {
|
||||
return "https://" + url;
|
||||
}
|
||||
@@ -1410,7 +1451,7 @@ export default class ProjectViewView extends Vue {
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
if (result.success) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -1442,7 +1483,13 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
|
||||
openHiddenDidDialog() {
|
||||
const shortestProjectId = this.projectId.startsWith(
|
||||
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
|
||||
)
|
||||
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
|
||||
: this.projectId;
|
||||
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
|
||||
"project/" + shortestProjectId,
|
||||
"creator",
|
||||
this.issuerVisibleToDids,
|
||||
this.allContacts,
|
||||
|
||||
@@ -155,7 +155,7 @@ import { Contact } from "../db/tables/contacts";
|
||||
import {
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
ErrorResult,
|
||||
CreateAndSubmitClaimResult,
|
||||
} from "../interfaces";
|
||||
import {
|
||||
BVC_MEETUPS_PROJECT_CLAIM_ID,
|
||||
@@ -298,28 +298,29 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
}
|
||||
|
||||
// in parallel, make a confirmation for each selected claim and send them all to the server
|
||||
const confirmResults = await Promise.allSettled(
|
||||
this.claimsToConfirmSelected.map(async (jwtId) => {
|
||||
const record = this.claimsToConfirm.find(
|
||||
(claim) => claim.id === jwtId,
|
||||
);
|
||||
if (!record) {
|
||||
return { type: "error", error: "Record not found." };
|
||||
}
|
||||
return createAndSubmitConfirmation(
|
||||
this.activeDid,
|
||||
record.claim as GenericVerifiableCredential,
|
||||
record.id,
|
||||
record.handleId,
|
||||
this.apiServer,
|
||||
axios,
|
||||
);
|
||||
}),
|
||||
);
|
||||
const confirmResults: PromiseSettledResult<CreateAndSubmitClaimResult>[] =
|
||||
await Promise.allSettled(
|
||||
this.claimsToConfirmSelected.map(async (jwtId) => {
|
||||
const record = this.claimsToConfirm.find(
|
||||
(claim) => claim.id === jwtId,
|
||||
);
|
||||
if (!record) {
|
||||
return { success: false, error: "Record not found." };
|
||||
}
|
||||
return createAndSubmitConfirmation(
|
||||
this.activeDid,
|
||||
record.claim as GenericVerifiableCredential,
|
||||
record.id,
|
||||
record.handleId,
|
||||
this.apiServer,
|
||||
axios,
|
||||
);
|
||||
}),
|
||||
);
|
||||
// check for any rejected confirmations
|
||||
const confirmsSucceeded = confirmResults.filter(
|
||||
(result) =>
|
||||
result.status === "fulfilled" && result.value.type === "success",
|
||||
// 'fulfilled' is the status in a successful PromiseFulfilledResult
|
||||
(result) => result.status === "fulfilled" && result.value.success,
|
||||
);
|
||||
if (confirmsSucceeded.length < this.claimsToConfirmSelected.length) {
|
||||
logger.error("Error sending confirmations:", confirmResults);
|
||||
@@ -353,7 +354,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
undefined,
|
||||
BVC_MEETUPS_PROJECT_CLAIM_ID,
|
||||
);
|
||||
giveSucceeded = giveResult.type === "success";
|
||||
giveSucceeded = giveResult.success;
|
||||
if (!giveSucceeded) {
|
||||
logger.error("Error sending give:", giveResult);
|
||||
this.$notify(
|
||||
@@ -362,7 +363,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
(giveResult as ErrorResult)?.error?.userMessage ||
|
||||
(giveResult as CreateAndSubmitClaimResult)?.error ||
|
||||
"There was an error sending that give.",
|
||||
},
|
||||
5000,
|
||||
|
||||
@@ -105,7 +105,7 @@ export default class ShareMyContactInfoView extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Copied",
|
||||
text: "Your contact info was copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.",
|
||||
text: "Your contact info was copied to the clipboard. Have them click on it, or paste it in the box on their 'Contacts' screen.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
|
||||
@@ -82,6 +82,18 @@
|
||||
Derive new address from existing seed
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Database Migration Section -->
|
||||
<div class="mt-8 pt-6 border-t border-gray-200">
|
||||
<div class="flex justify-center">
|
||||
<router-link
|
||||
:to="{ name: 'database-migration' }"
|
||||
class="block w-fit 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-4 py-2 rounded-md"
|
||||
>
|
||||
Migrate My Old Data
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -182,6 +182,15 @@
|
||||
>
|
||||
Accounts
|
||||
</button>
|
||||
<button
|
||||
class="text-sm text-blue-600 hover:text-blue-800 underline"
|
||||
@click="
|
||||
sqlQuery = 'SELECT * FROM contacts;';
|
||||
executeSql();
|
||||
"
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
<button
|
||||
class="text-sm text-blue-600 hover:text-blue-800 underline"
|
||||
@click="
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</button>
|
||||
Individual Profile
|
||||
</h1>
|
||||
<div class="text-sm text-center text-slate-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
@@ -32,6 +33,12 @@
|
||||
<div class="text-sm">
|
||||
<font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome>
|
||||
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
|
||||
<button title="Copy Link to Profile" @click="onCopyLinkClick()">
|
||||
<font-awesome
|
||||
icon="link"
|
||||
class="text-sm text-slate-500 ml-2 mb-1"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="profile.description" class="mt-4 text-slate-600">
|
||||
{{ profile.description }}
|
||||
@@ -100,6 +107,7 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import {
|
||||
APP_SERVER,
|
||||
DEFAULT_PARTNER_API_SERVER,
|
||||
NotificationIface,
|
||||
USE_DEXIE_DB,
|
||||
@@ -113,6 +121,7 @@ import { retrieveAccountDids } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { Settings } from "@/db/tables/settings";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
@Component({
|
||||
components: {
|
||||
LMap,
|
||||
@@ -186,6 +195,10 @@ export default class UserProfileView extends Vue {
|
||||
if (response.status === 200) {
|
||||
const result = await response.json();
|
||||
this.profile = result.data;
|
||||
if (this.profile && this.profile.rowId !== profileId) {
|
||||
// currently the server returns "rowid" with lowercase "i"; remove when that's fixed
|
||||
this.profile.rowId = profileId;
|
||||
}
|
||||
} else {
|
||||
throw new Error("Failed to load profile");
|
||||
}
|
||||
@@ -204,5 +217,22 @@ export default class UserProfileView extends Vue {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onCopyLinkClick() {
|
||||
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
||||
useClipboard()
|
||||
.copy(deepLink)
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: "A link to this profile was copied to the clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user