Browse Source

Merge branch 'master' into test-playwright

Jose Olarte III 3 months ago
parent
commit
d679d0c804
  1. 24
      CHANGELOG.md
  2. 9
      CONTRIBUTING.md
  3. 2
      README.md
  4. 4
      package-lock.json
  5. 2
      package.json
  6. 6
      playwright.config-local.ts
  7. 2
      src/components/GiftedDialog.vue
  8. 47
      src/components/OfferDialog.vue
  9. 4
      src/db/tables/contacts.ts
  10. 181
      src/libs/endorserServer.ts
  11. 33
      src/libs/util.ts
  12. 1
      src/registerServiceWorker.ts
  13. 10
      src/router/index.ts
  14. 17
      src/views/AccountViewView.vue
  15. 66
      src/views/ClaimView.vue
  16. 2
      src/views/ContactAmountsView.vue
  17. 4
      src/views/ContactQRScanShowView.vue
  18. 595
      src/views/ContactsView.vue
  19. 444
      src/views/DIDView.vue
  20. 2
      src/views/DiscoverView.vue
  21. 7
      src/views/GiftedDetailsView.vue
  22. 4
      src/views/NewEditProjectView.vue
  23. 633
      src/views/OfferDetailsView.vue
  24. 7
      src/views/ProjectViewView.vue
  25. 70
      src/views/ProjectsView.vue
  26. 13
      src/views/SharedPhotoView.vue
  27. 15
      sw_scripts/safari-notifications.js
  28. 6
      test-playwright/20-create-project.spec.ts
  29. 16
      test-playwright/30-record-gift.spec.ts
  30. 41
      test-playwright/35-record-gift-from-image-share.spec.ts
  31. 13
      test-playwright/40-add-contact.spec.ts
  32. 63
      test-playwright/50-record-offer.spec.ts
  33. 6
      test-playwright/testUtils.ts
  34. 2
      vite.config.mjs

24
CHANGELOG.md

@ -6,7 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.16] - 2024.07.10 ## ?
### Added
- Send list of contacts to someone
### Changed
- Moved contact actions from list onto detail page
## [0.3.20] - 2024.08.18 - 4064eb75a9743ca268bf00016fa0a5fc5dec4e30
### Fixed
- Bad "give" verbiage on offer page
- Failing offer test
## [0.3.19] - 2024.08.18 - ee9c14942ceba993bf21a11249601f205158ec71
### Added
- Update of an offer
- Recipient description in offer list
### Fixed
- List of offers wasn't showing.
- Destination page after sharing photo was wrong.
## [0.3.17] - 2024.07.11 - cefa384ff1a2d922848c370640c096c529920fab
### Added ### Added
- Photos on more screens - Photos on more screens
### Fixed ### Fixed

9
CONTRIBUTING.md

@ -2,5 +2,10 @@
Welcome! We are happy to have your help with this project. Welcome! We are happy to have your help with this project.
Note that all contributions will be under our We expect contributions to include automated tests and pass linting. Run the `test-all` task.
[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE). Note that some previous features don't have tests and adding more will make you friends quick.
Note that all contributions will be under our [license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
If you want to see a code of conduct, we're probably not the people you want to hang with.
Basically, we'll work together as long as we both enjoy it, and we'll stop when that stops.

2
README.md

@ -39,6 +39,8 @@ npm run lint
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`. * Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Commit everything (since the commit hash is used the app).
* Record what version is currently on production. * Record what version is currently on production.
* Run the correct build: * Run the correct build:

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.17-beta", "version": "0.3.21-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.17-beta", "version": "0.3.21-beta",
"dependencies": { "dependencies": {
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.17-beta", "version": "0.3.21-beta",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",

6
playwright.config-local.ts

@ -72,9 +72,9 @@ export default defineConfig({
}, },
], ],
/* Configure global timeout */ /* Configure global timeout; default is 30000 milliseconds */
// the image upload will often not succeed at 5 seconds // the image upload will often not succeed at 5 seconds
//timeout: 7000, timeout: 20000,
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
/** /**
@ -86,7 +86,7 @@ export default defineConfig({
* }, * },
* *
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails. * But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
* It is worth considering a change such that Time Safari's default Endorer API server is NOT set * It is worth considering a change such that Time Safari's default Endorser API server is NOT set
* in the user's settings so that it can be blanked out and the default is used. * in the user's settings so that it can be blanked out and the default is used.
*/ */
webServer: { webServer: {

2
src/components/GiftedDialog.vue

@ -55,7 +55,7 @@
}" }"
class="text-blue-500" class="text-blue-500"
> >
Photo & Details ... Photo & more options ...
</router-link> </router-link>
</span> </span>
</div> </div>

47
src/components/OfferDialog.vue

@ -4,6 +4,7 @@
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1> <h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
<input <input
type="text" type="text"
data-testId="inputDescription"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2" class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Description, prerequisites, terms, etc." placeholder="Description, prerequisites, terms, etc."
v-model="description" v-model="description"
@ -23,6 +24,7 @@
<fa icon="chevron-left" /> <fa icon="chevron-left" />
</div> </div>
<input <input
data-testId="inputOfferAmount"
type="number" type="number"
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center" class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
v-model="amountInput" v-model="amountInput"
@ -34,18 +36,27 @@
<fa icon="chevron-right" /> <fa icon="chevron-right" />
</div> </div>
</div> </div>
<div class="flex flex-row mt-2"> <div class="mt-4 flex justify-center">
<span <span>
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2" <router-link
> :to="{
Expiration name: 'offer-details',
query: {
amountInput,
description,
offererDid: activeDid,
projectId,
projectName,
recipientDid,
recipientName,
unitCode: amountUnitCode,
},
}"
class="text-blue-500"
>
Conditions & more options...
</router-link>
</span> </span>
<input
type="text"
class="w-full border border-slate-400 px-2 py-2 rounded-r"
:placeholder="datePlaceholder()"
v-model="expirationDateInput"
/>
</div> </div>
<p class="text-center mt-6 mb-2 italic"> <p class="text-center mt-6 mb-2 italic">
Sign & Send to publish to the world Sign & Send to publish to the world
@ -69,7 +80,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { DateTime } from "luxon";
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
@ -82,7 +92,8 @@ import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
export default class OfferDialog extends Vue { export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId? = ""; @Prop projectId?;
@Prop projectName?;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
@ -92,13 +103,15 @@ export default class OfferDialog extends Vue {
description = ""; description = "";
expirationDateInput = ""; expirationDateInput = "";
recipientDid? = ""; recipientDid? = "";
recipientName? = "";
visible = false; visible = false;
libsUtil = libsUtil; libsUtil = libsUtil;
async open(recipientDid?: string) { async open(recipientDid?: string, recipientName?: string) {
try { try {
this.recipientDid = recipientDid; this.recipientDid = recipientDid;
this.recipientName = recipientName;
await db.open(); await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
@ -144,12 +157,6 @@ export default class OfferDialog extends Vue {
)}`; )}`;
} }
datePlaceholder() {
return (
"Date, eg. " + DateTime.now().plus({ month: 1 }).toISO().slice(0, 10)
);
}
cancel() { cancel() {
this.close(); this.close();
this.eraseValues(); this.eraseValues();

4
src/db/tables/contacts.ts

@ -4,8 +4,8 @@ export interface Contact {
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
profileImageUrl?: string; profileImageUrl?: string;
publicKeyBase64?: string; publicKeyBase64?: string;
seesMe?: boolean; seesMe?: boolean; // cached value of the server setting
registered?: boolean; registered?: boolean; // cached value of the server setting
} }
export const ContactSchema = { export const ContactSchema = {

181
src/libs/endorserServer.ts

@ -48,7 +48,7 @@ export interface ClaimResult {
} }
export interface GenericVerifiableCredential { export interface GenericVerifiableCredential {
"@context"?: string; "@context"?: string; // optional when embedded, eg. in an Agree
"@type": string; "@type": string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
} }
@ -62,6 +62,7 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
id: string; id: string;
issuedAt: string; issuedAt: string;
issuer: string; issuer: string;
publicUrls?: Record<string, string>; // only for IDs that want to be public
} }
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> = export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{ {
@ -139,13 +140,13 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8 // https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential { export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree "@context"?: string; // optional when embedded... though it doesn't make sense to agree to an offer
"@type": "Offer"; "@type": "Offer";
description?: string; description?: string; // conditions for the offer
includesObject?: { amountOfThisGood: number; unitCode: string }; includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: { itemOffered?: {
description?: string; description?: string; // description of the item
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string }; isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
}; };
offeredBy?: { identifier: string }; offeredBy?: { identifier: string };
@ -155,7 +156,7 @@ export interface OfferVerifiableCredential {
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7 // https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential { export interface PlanVerifiableCredential extends GenericVerifiableCredential {
"@context": "https://schema.org"; "@context": "https://schema.org";
"@type": "PlanAction"; "@type": "PlanAction";
name: string; name: string;
@ -267,10 +268,6 @@ export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6 // See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN"; const HIDDEN_DID = "did:none:HIDDEN";
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
export function isDid(did: string) { export function isDid(did: string) {
return did.startsWith("did:"); return did.startsWith("did:");
} }
@ -507,6 +504,10 @@ export async function getHeaders(did?: string) {
return headers; return headers;
} }
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
/** /**
* @param handleId nullable, in which case "undefined" will be returned * @param handleId nullable, in which case "undefined" will be returned
* @param requesterDid optional, in which case no private info will be returned * @param requesterDid optional, in which case no private info will be returned
@ -563,6 +564,8 @@ export async function setPlanInCache(
/** /**
* Construct GiveAction VC for submission to server * Construct GiveAction VC for submission to server
*
* @param lastClaimId supplied when editing a previous claim
*/ */
export function hydrateGive( export function hydrateGive(
vcClaimOrig?: GiveVerifiableCredential, vcClaimOrig?: GiveVerifiableCredential,
@ -587,6 +590,7 @@ export function hydrateGive(
}; };
if (lastClaimId) { if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId; vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier; delete vcClaim.identifier;
} }
@ -594,16 +598,17 @@ export function hydrateGive(
vcClaim.agent = fromDid ? { identifier: fromDid } : undefined; vcClaim.agent = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined; vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
vcClaim.description = description || undefined; vcClaim.description = description || undefined;
vcClaim.object = amount vcClaim.object =
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" } amount && !isNaN(amount)
: undefined; ? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
// ensure fulfills is an array // ensure fulfills is an array
if (!Array.isArray(vcClaim.fulfills)) { if (!Array.isArray(vcClaim.fulfills)) {
vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : []; vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
} }
// ... and replace or add each element, ending with Trade or Donate // ... and replace or add each element, ending with Trade or Donate
// I realize this doesn't change any elements that are not PlanAction or Offer or Trade/Action. // I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action.
vcClaim.fulfills = vcClaim.fulfills.filter( vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "PlanAction", (elem) => elem["@type"] !== "PlanAction",
); );
@ -639,8 +644,8 @@ export function hydrateGive(
* *
* @param fromDid may be null * @param fromDid may be null
* @param toDid * @param toDid
* @param description may be null; should have this or amount * @param description may be null
* @param amount may be null; should have this or description * @param amount may be null
*/ */
export async function createAndSubmitGive( export async function createAndSubmitGive(
axios: Axios, axios: Axios,
@ -667,6 +672,7 @@ export async function createAndSubmitGive(
fulfillsOfferHandleId, fulfillsOfferHandleId,
isTrade, isTrade,
imageUrl, imageUrl,
undefined,
); );
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as GenericVerifiableCredential, vcClaim as GenericVerifiableCredential,
@ -680,9 +686,9 @@ export async function createAndSubmitGive(
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
* *
* @param fromDid may be null * @param fromDid may be null
* @param toDid * @param toDid may be null if project is provided
* @param description may be null; should have this or amount * @param description may be null
* @param amount may be null; should have this or description * @param amount may be null
*/ */
export async function editAndSubmitGive( export async function editAndSubmitGive(
axios: Axios, axios: Axios,
@ -720,51 +726,128 @@ export async function editAndSubmitGive(
); );
} }
/**
* Construct Offer VC for submission to server
*
* @param lastClaimId supplied when editing a previous claim
*/
export function hydrateOffer(
vcClaimOrig?: OfferVerifiableCredential,
fromDid?: string,
toDid?: string,
itemDescription?: string,
amount?: number,
unitCode?: string,
conditionDescription?: string,
fulfillsProjectHandleId?: string,
validThrough?: string,
lastClaimId?: string,
): OfferVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim: OfferVerifiableCredential = vcClaimOrig
? R.clone(vcClaimOrig)
: {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "Offer",
};
if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier;
}
vcClaim.offeredBy = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
vcClaim.description = conditionDescription || undefined;
vcClaim.includesObject =
amount && !isNaN(amount)
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
if (itemDescription || fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.description = itemDescription || undefined;
if (fulfillsProjectHandleId) {
vcClaim.itemOffered.isPartOf = {
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
};
}
}
vcClaim.validThrough = validThrough || undefined;
return vcClaim;
}
/** /**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
* *
* @param identity * @param identity
* @param description may be null; should have this or amount * @param description may be null
* @param amount may be null; should have this or description * @param amount may be null
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null) * @param validThrough ISO 8601 date string YYYY-MM-DD (may be null)
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null) * @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
*/ */
export async function createAndSubmitOffer( export async function createAndSubmitOffer(
axios: Axios, axios: Axios,
apiServer: string, apiServer: string,
issuerDid: string, issuerDid: string,
description?: string, itemDescription: string,
amount?: number, amount?: number,
unitCode?: string, unitCode?: string,
expirationDate?: string, conditionDescription?: string,
validThrough?: string,
recipientDid?: string, recipientDid?: string,
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {
const vcClaim: OfferVerifiableCredential = { const vcClaim = hydrateOffer(
"@context": SCHEMA_ORG_CONTEXT, undefined,
"@type": "Offer", issuerDid,
offeredBy: { identifier: issuerDid }, recipientDid,
validThrough: expirationDate || undefined, itemDescription,
}; amount,
if (amount) { unitCode,
vcClaim.includesObject = { conditionDescription,
amountOfThisGood: amount, fulfillsProjectHandleId,
unitCode: unitCode || "HUR", validThrough,
}; undefined,
} );
if (description) { return createAndSubmitClaim(
vcClaim.itemOffered = { description }; vcClaim as OfferVerifiableCredential,
} issuerDid,
if (recipientDid) { apiServer,
vcClaim.recipient = { identifier: recipientDid }; axios,
} );
if (fulfillsProjectHandleId) { }
vcClaim.itemOffered = vcClaim.itemOffered || {};
vcClaim.itemOffered.isPartOf = { export async function editAndSubmitOffer(
"@type": "PlanAction", axios: Axios,
identifier: fulfillsProjectHandleId, apiServer: string,
}; fullClaim: GenericCredWrapper<OfferVerifiableCredential>,
} issuerDid: string,
itemDescription: string,
amount?: number,
unitCode?: string,
conditionDescription?: string,
validThrough?: string,
recipientDid?: string,
fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim = hydrateOffer(
fullClaim.claim,
issuerDid,
recipientDid,
itemDescription,
amount,
unitCode,
conditionDescription,
fulfillsProjectHandleId,
validThrough,
fullClaim.id,
);
return createAndSubmitClaim( return createAndSubmitClaim(
vcClaim as OfferVerifiableCredential, vcClaim as OfferVerifiableCredential,
issuerDid, issuerDid,

33
src/libs/util.ts

@ -1,11 +1,14 @@
// many of these are also found in endorser-mobile utility.ts // many of these are also found in endorser-mobile utility.ts
import axios, { AxiosResponse } from "axios"; import axios, { AxiosResponse } from "axios";
import { Buffer } from "buffer";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER } from "@/constants/app"; import { DEFAULT_PUSH_SERVER } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES, DEFAULT_PASSKEY_EXPIRATION_MINUTES,
MASTER_SETTINGS_KEY, MASTER_SETTINGS_KEY,
@ -18,11 +21,9 @@ import {
OfferVerifiableCredential, OfferVerifiableCredential,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import * as serverUtil from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer";
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
import { Buffer } from "buffer";
import { KeyMeta } from "@/libs/crypto/vc"; import { KeyMeta } from "@/libs/crypto/vc";
import { createPeerDid } from "@/libs/crypto/vc/didPeer"; import { createPeerDid } from "@/libs/crypto/vc/didPeer";
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
export const PRIVACY_MESSAGE = export const PRIVACY_MESSAGE =
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow."; "The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
@ -30,8 +31,8 @@ export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = { export const UNIT_SHORT: Record<string, string> = {
"BX": "BX",
"BTC": "BTC", "BTC": "BTC",
"BX": "BX",
"ETH": "ETH", "ETH": "ETH",
"HUR": "Hours", "HUR": "Hours",
"USD": "US $", "USD": "US $",
@ -40,8 +41,8 @@ export const UNIT_SHORT: Record<string, string> = {
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = { export const UNIT_LONG: Record<string, string> = {
"BX": "Buxbe",
"BTC": "Bitcoin", "BTC": "Bitcoin",
"BX": "Buxbe",
"ETH": "Ethereum", "ETH": "Ethereum",
"HUR": "hours", "HUR": "hours",
"USD": "dollars", "USD": "dollars",
@ -91,6 +92,28 @@ export const isGiveAction = (
return veriClaim.claimType === "GiveAction"; return veriClaim.claimType === "GiveAction";
}; };
export const nameForDid = (
activeDid: string,
contacts: Array<Contact>,
did: string,
): string => {
if (did === activeDid) {
return "you";
}
const contact = R.find((con) => con.did == did, contacts);
return nameForContact(contact);
};
export const nameForContact = (
contact?: Contact,
capitalize?: boolean,
): string => {
return (
(contact?.name as string) ||
(capitalize ? "This" : "this") + " unnamed user"
);
};
export const doCopyTwoSecRedo = (text: string, fn: () => void) => { export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
fn(); fn();
useClipboard() useClipboard()

1
src/registerServiceWorker.ts

@ -2,6 +2,7 @@
import { register } from "register-service-worker"; import { register } from "register-service-worker";
// NODE_ENV is "production" by default with "vite build". See https://vitejs.dev/guide/env-and-mode
if (import.meta.env.NODE_ENV === "production") { if (import.meta.env.NODE_ENV === "production") {
register("/sw_scripts-combined.js", { register("/sw_scripts-combined.js", {
ready() { ready() {

10
src/router/index.ts

@ -91,7 +91,7 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: "/gifted-details", path: "/gifted-details",
name: "gifted-details", name: "gifted-details",
component: () => import("../views/GiftedDetails.vue"), component: () => import("@/views/GiftedDetailsView.vue"),
}, },
{ {
path: "/help", path: "/help",
@ -143,6 +143,11 @@ const routes: Array<RouteRecordRaw> = [
name: "new-identifier", name: "new-identifier",
component: () => import("../views/NewIdentifierView.vue"), component: () => import("../views/NewIdentifierView.vue"),
}, },
{
path: "/offer-details/:id?",
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
},
{ {
path: "/project/:id?", path: "/project/:id?",
name: "project", name: "project",
@ -189,6 +194,9 @@ const routes: Array<RouteRecordRaw> = [
name: "shared-photo", name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"), component: () => import("@/views/SharedPhotoView.vue"),
}, },
// /share-target is also an endpoint in the service worker
{ {
path: "/start", path: "/start",
name: "start", name: "start",

17
src/views/AccountViewView.vue

@ -831,10 +831,16 @@ export default class AccountViewView extends Vue {
* Beware! I've seen where we never get to this point because "ready" never resolves. * Beware! I've seen where we never get to this point because "ready" never resolves.
*/ */
} catch (error) { } catch (error) {
// this can happen when running automated tests in dev mode because notifications don't work
console.error( console.error(
"Telling user to clear cache at page create because:", "Telling user to clear cache at page create because:",
error, error,
); );
// this sometimes gives different information on the error
console.error(
"Telling user to clear cache at page create because (error added): " +
error,
);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -1282,17 +1288,6 @@ export default class AccountViewView extends Vue {
} }
} catch (error) { } catch (error) {
this.handleRateLimitsError(error); this.handleRateLimitsError(error);
try {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: false,
});
this.isRegistered = false;
} catch (err) {
console.error("Got an error marking user not registered:", err);
// already set an error notification for the user
}
} }
this.loadingLimits = false; this.loadingLimits = false;

66
src/views/ClaimView.vue

@ -24,13 +24,15 @@
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }} {{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
<button <button
v-if=" v-if="
veriClaim.claimType === 'GiveAction' && ['GiveAction', 'Offer'].includes(
veriClaim.issuer === activeDid veriClaim.claimType as string,
) && veriClaim.issuer === activeDid
" "
@click="onClickEditClaim" @click="onClickEditClaim"
title="Edit" title="Edit"
data-testId="editClaimButton"
> >
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1"></fa> <fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button> </button>
</h2> </h2>
<div class="text-sm"> <div class="text-sm">
@ -49,9 +51,12 @@
</button> </button>
<span v-show="showIdCopy">Copied ID</span> <span v-show="showIdCopy">Copied ID</span>
</div> </div>
<div> <div data-testId="description">
<fa icon="message" class="fa-fw text-slate-400" /> <fa icon="message" class="fa-fw text-slate-400" />
{{ veriClaim.claim?.description }} {{
veriClaim.claim?.itemOffered?.description ||
veriClaim.claim?.description
}}
</div> </div>
<div> <div>
<fa icon="user" class="fa-fw text-slate-400" /> <fa icon="user" class="fa-fw text-slate-400" />
@ -399,7 +404,7 @@
<!-- Keep the dump contents directly between > and < to avoid weird spacing. --> <!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre <pre
v-if="showVeriClaimDump" v-if="showVeriClaimDump"
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ veriClaimDump }}</pre >{{ veriClaimDump }}</pre
> >
</div> </div>
@ -422,7 +427,10 @@
</button> </button>
</div> </div>
<div v-else> <div v-else>
<pre>{{ fullClaimDump }}</pre> <pre
class="text-sm overflow-x-scroll bg-slate-100 px-4 py-3 rounded-md"
>{{ fullClaimDump }}</pre
>
</div> </div>
<a <a
@ -840,15 +848,41 @@ export default class ClaimView extends Vue {
} }
onClickEditClaim() { onClickEditClaim() {
const route = { if (this.veriClaim.claimType === "GiveAction") {
name: "gifted-details", const route = {
query: { name: "gifted-details",
prevCredToEdit: JSON.stringify(this.veriClaim), query: {
destinationPathAfter: prevCredToEdit: JSON.stringify(this.veriClaim),
"/claim/" + encodeURIComponent(this.veriClaim.handleId), destinationPathAfter:
}, "/claim/" + encodeURIComponent(this.veriClaim.handleId),
}; },
(this.$router as Router).push(route); };
(this.$router as Router).push(route);
} else if (this.veriClaim.claimType === "Offer") {
const route = {
name: "offer-details",
query: {
prevCredToEdit: JSON.stringify(this.veriClaim),
destinationPathAfter:
"/claim/" + encodeURIComponent(this.veriClaim.handleId),
},
};
(this.$router as Router).push(route);
} else {
console.error(
"Unrecognized claim type for edit:",
this.veriClaim.claimType,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "This is an unrecognized claim type.",
},
3000,
);
}
} }
} }
</script> </script>

2
src/views/ContactAmountsView.vue

@ -271,7 +271,7 @@ export default class ContactAmountssView extends Vue {
// Make the xhr request payload // Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const payload = JSON.stringify({ jwtEncoded: vcJwt });
const url = this.apiServer + "/api/v2/claim"; const url = this.apiServer + "/api/v2/claim";
const headers = getHeaders(this.activeDid) as AxiosRequestHeaders; const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
try { try {
const resp = await this.axios.post(url, payload, { headers }); const resp = await this.axios.post(url, payload, { headers });

4
src/views/ContactQRScanShowView.vue

@ -458,9 +458,9 @@ export default class ContactQRScanShow extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Copied", title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it on their 'People' screen to add you.", text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.",
}, },
10000, 5000,
); );
}); });
} }

595
src/views/ContactsView.vue

@ -37,18 +37,47 @@
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400" class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
@click="onClickNewContact()" @click="onClickNewContact()"
> >
<fa icon="plus" class="fa-fw"></fa> <fa icon="plus" class="fa-fw" />
</button> </button>
</div> </div>
<div class="w-full text-right"> <div class="flex justify-between">
<button <div class="w-full text-left">
href="" <input
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-1 py-1 rounded-md" type="checkbox"
@click="toggleShowContactAmounts()" v-if="!showGiveNumbers"
> :checked="contactsSelected.length === contacts.length"
{{ showGiveNumbers ? "Hide Given Hours" : "Show Given Hours" }} @click="
</button> contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
/>
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
>
Copy Selected Contacts
</button>
</div>
<div class="w-full text-right">
<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-1 py-1 rounded-md"
@click="toggleShowContactAmounts()"
>
{{ showGiveNumbers ? "Hide Given Hours" : "Show Given Hours" }}
</button>
</div>
</div> </div>
<div class="flex justify-between mt-1" v-if="showGiveNumbers"> <div class="flex justify-between mt-1" v-if="showGiveNumbers">
<div class="w-full text-right"> <div class="w-full text-right">
@ -82,147 +111,51 @@
<ul <ul
id="listContacts" id="listContacts"
v-if="contacts.length > 0" v-if="contacts.length > 0"
class="border-t border-slate-300" class="border-t border-slate-300 mt-1"
> >
<li <li
class="border-b border-slate-300 pt-2.5 pb-4" class="border-b border-slate-300 pt-1 pb-1"
v-for="contact in contacts" v-for="contact in filteredContacts()"
:key="contact.did" :key="contact.did"
> >
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
<h2 class="text-base font-semibold"> <div class="flex items-center">
<EntityIcon <EntityIcon
:contact="contact" :contact="contact"
:iconSize="24" :iconSize="24"
class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer" class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer"
@click="showLargeIdenticon = contact" @click="showLargeIdenticon = contact"
/> />
{{ contact.name || AppString.NO_CONTACT_NAME }}
<button <input
type="checkbox"
v-if="!showGiveNumbers"
:checked="contactsSelected.includes(contact.did)"
@click=" @click="
contactEdit = contact; contactsSelected.includes(contact.did)
contactNewName = contact.name || ''; ? contactsSelected.splice(
contactsSelected.indexOf(contact.did),
1,
)
: contactsSelected.push(contact.did)
" "
title="Edit" class="ml-2 h-6 w-6"
> />
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1"></fa>
</button> <h2 class="text-base font-semibold ml-2">
{{ contact.name || AppString.NO_CONTACT_NAME }}
</h2>
<router-link <router-link
:to="{ :to="{
path: '/did/' + encodeURIComponent(contact.did), path: '/did/' + encodeURIComponent(contact.did),
}" }"
title="See more about this DID" title="See more about this person"
> >
<fa icon="circle-info" class="text-blue-500 ml-4" /> <fa icon="circle-info" class="text-blue-500 ml-4" />
</router-link> </router-link>
</h2>
<div class="text-sm truncate">
Identifier:
<button
@click="
libsUtil.doCopyTwoSecRedo(
contact.did,
() => (showDidCopy = !showDidCopy),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy" class="text-green-500">Copied DID</span>
{{ contact.did }}
</div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
Public Key (base 64):
<button
@click="
libsUtil.doCopyTwoSecRedo(
contact.publicKeyBase64,
() => (showPubKeyCopy = !showPubKeyCopy),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showPubKeyCopy" class="text-green-500"
>Copied Key</span
>
{{ contact.publicKeyBase64 }}
</div>
<div class="text-sm truncate" v-if="contact.nextPubKeyHashB64">
Next Public Key Hash (base 64):
<button
@click="
libsUtil.doCopyTwoSecRedo(
contact.nextPubKeyHashB64,
() => (showPubKeyHashCopy = !showPubKeyHashCopy),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showPubKeyHashCopy" class="text-green-500"
>Copied Hash</span
>
{{ contact.nextPubKeyHashB64 }}
</div> </div>
<div id="ContactActions" class="flex gap-1.5 mt-2"> <div id="ContactActions" class="flex gap-1.5 mt-2">
<div v-if="activeDid">
<button
v-if="contact.seesMe && contact.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contact, false)"
title="They can see you"
>
<fa icon="eye" class="fa-fw" />
</button>
<button
v-else-if="!contact.seesMe && contact.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contact, true)"
title="They cannot see you"
>
<fa icon="eye-slash" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="eye" class="text-white mx-2.5" />
<button
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="checkVisibility(contact)"
title="Check Visibility"
v-if="contact.did !== activeDid"
>
<fa icon="rotate" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white mx-2.5" />
<button
@click="confirmRegister(contact)"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 px-2 py-1.5 rounded-md"
v-if="contact.did !== activeDid"
title="Registration"
>
<fa
v-if="contact.registered"
icon="person-circle-check"
class="fa-fw"
/>
<fa v-else icon="person-circle-question" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white ml-6 px-2.5" />
</div>
<button
@click="confirmDeleteContact(contact)"
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 px-2 py-1.5 rounded-md"
title="Delete"
>
<fa icon="trash-can" class="fa-fw" />
</button>
<div <div
v-if="showGiveNumbers && contact.did != activeDid" v-if="showGiveNumbers && contact.did != activeDid"
class="ml-auto flex gap-1.5" class="ml-auto flex gap-1.5"
@ -271,7 +204,7 @@
<button <button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400" class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400"
@click="openOfferDialog(contact.did)" @click="openOfferDialog(contact.did, contact.name)"
> >
Offer Offer
</button> </button>
@ -293,6 +226,33 @@
</ul> </ul>
<p v-else>There are no contacts.</p> <p v-else>There are no contacts.</p>
<div class="mt-2 w-full text-left">
<input
type="checkbox"
v-if="!showGiveNumbers"
:checked="contactsSelected.length === contacts.length"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
/>
<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 ml-2 px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
>
Copy Selected Contacts
</button>
</div>
<GiftedDialog ref="customGivenDialog" /> <GiftedDialog ref="customGivenDialog" />
<OfferDialog ref="customOfferDialog" /> <OfferDialog ref="customOfferDialog" />
@ -308,33 +268,6 @@
/> />
</div> </div>
</div> </div>
<div v-if="contactEdit !== null" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Name"
v-model="contactNewName"
/>
<div class="flex justify-between">
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickSaveName(contactEdit, contactNewName)"
>
<fa icon="save" />
</button>
<span class="inline-block w-2" />
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickCancelName()"
>
<fa icon="ban" />
</button>
</div>
</div>
</div>
</section> </section>
</template> </template>
@ -345,6 +278,7 @@ import { IndexableType } from "dexie";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { AppString, NotificationIface } from "@/constants/app"; import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index"; import { db } from "@/db/index";
@ -379,6 +313,7 @@ export default class ContactsView extends Vue {
contactInput = ""; contactInput = "";
contactEdit: Contact | null = null; contactEdit: Contact | null = null;
contactNewName = ""; contactNewName = "";
contactsSelected: Array<string> = [];
// { "did:...": concatenated-descriptions } entry for each contact // { "did:...": concatenated-descriptions } entry for each contact
givenByMeDescriptions: Record<string, string> = {}; givenByMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact // { "did:...": amount } entry for each contact
@ -404,7 +339,7 @@ export default class ContactsView extends Vue {
AppString = AppString; AppString = AppString;
libsUtil = libsUtil; libsUtil = libsUtil;
async created() { public async created() {
await db.open(); await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
@ -427,7 +362,7 @@ export default class ContactsView extends Vue {
); );
} }
danger(message: string, title: string = "Error", timeout = 5000) { private danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -439,7 +374,17 @@ export default class ContactsView extends Vue {
); );
} }
async loadGives() { private filteredContacts() {
return this.showGiveNumbers
? this.contactsSelected.length === 0
? this.contacts
: this.contacts.filter((contact) =>
this.contactsSelected.includes(contact.did),
)
: this.contacts;
}
private async loadGives() {
if (!this.activeDid) { if (!this.activeDid) {
return; return;
} }
@ -546,19 +491,20 @@ export default class ContactsView extends Vue {
} }
} }
async onClickNewContact(): Promise<void> { private async onClickNewContact(): Promise<void> {
if (!this.contactInput) { const contactInput = this.contactInput.trim();
if (!contactInput) {
this.danger("There was no contact info to add.", "No Contact"); this.danger("There was no contact info to add.", "No Contact");
return; return;
} }
if (this.contactInput.startsWith(CONTACT_URL_PREFIX)) { if (contactInput.startsWith(CONTACT_URL_PREFIX)) {
await this.addContactFromScan(this.contactInput); await this.addContactFromScan(contactInput);
return; return;
} }
if (this.contactInput.startsWith(CONTACT_CSV_HEADER)) { if (contactInput.startsWith(CONTACT_CSV_HEADER)) {
const lines = this.contactInput.split(/\n/); const lines = contactInput.split(/\n/);
const lineAdded = []; const lineAdded = [];
for (const line of lines) { for (const line of lines) {
if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) { if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) {
@ -590,44 +536,71 @@ export default class ContactsView extends Vue {
return; return;
} }
let did = this.contactInput; if (contactInput.startsWith("did:")) {
let name, publicKeyInput, nextPublicKeyHashInput; let did = contactInput;
const commaPos1 = this.contactInput.indexOf(","); let name, publicKeyInput, nextPublicKeyHashInput;
if (commaPos1 > -1) { const commaPos1 = contactInput.indexOf(",");
did = this.contactInput.substring(0, commaPos1).trim(); if (commaPos1 > -1) {
name = this.contactInput.substring(commaPos1 + 1).trim(); did = contactInput.substring(0, commaPos1).trim();
const commaPos2 = this.contactInput.indexOf(",", commaPos1 + 1); name = contactInput.substring(commaPos1 + 1).trim();
if (commaPos2 > -1) { const commaPos2 = contactInput.indexOf(",", commaPos1 + 1);
name = this.contactInput.substring(commaPos1 + 1, commaPos2).trim(); if (commaPos2 > -1) {
publicKeyInput = this.contactInput.substring(commaPos2 + 1).trim(); name = contactInput.substring(commaPos1 + 1, commaPos2).trim();
const commaPos3 = this.contactInput.indexOf(",", commaPos2 + 1); publicKeyInput = contactInput.substring(commaPos2 + 1).trim();
if (commaPos3 > -1) { const commaPos3 = contactInput.indexOf(",", commaPos2 + 1);
publicKeyInput = this.contactInput.substring(commaPos2 + 1, commaPos3).trim(); // eslint-disable-line prettier/prettier if (commaPos3 > -1) {
nextPublicKeyHashInput = this.contactInput.substring(commaPos3 + 1).trim(); // eslint-disable-line prettier/prettier publicKeyInput = contactInput.substring(commaPos2 + 1, commaPos3).trim(); // eslint-disable-line prettier/prettier
nextPublicKeyHashInput = contactInput.substring(commaPos3 + 1).trim(); // eslint-disable-line prettier/prettier
}
} }
} }
// 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",
);
}
let nextPubKeyHashB64 = nextPublicKeyHashInput;
if (nextPubKeyHashB64 && /^[0-9A-Fa-f]{66}$/i.test(nextPubKeyHashB64)) {
// it must be all hex (compressed public key), so convert
nextPubKeyHashB64 = Buffer.from(nextPubKeyHashB64, "hex").toString("base64"); // eslint-disable-line prettier/prettier
}
const newContact = {
did,
name,
publicKeyBase64,
nextPubKeyHashB64: nextPubKeyHashB64,
};
await this.addContact(newContact);
return;
} }
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput; if (contactInput.includes("[")) {
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) { // assume there's a JSON array of contacts in the input
// it must be all hex (compressed public key), so convert const jsonContactInput = contactInput.substring(
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64"); contactInput.indexOf("["),
} contactInput.lastIndexOf("]") + 1,
let nextPubKeyHashB64 = nextPublicKeyHashInput; );
if (nextPubKeyHashB64 && /^[0-9A-Fa-f]{66}$/i.test(nextPubKeyHashB64)) { try {
// it must be all hex (compressed public key), so convert const contacts = JSON.parse(jsonContactInput);
nextPubKeyHashB64 = Buffer.from(nextPubKeyHashB64, "hex").toString("base64"); // eslint-disable-line prettier/prettier (this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contacts) },
});
} catch (e) {
this.danger("The input could not be parsed.", "Invalid Contact List");
}
return;
} }
const newContact = {
did, this.danger("No contact info was found in that input.", "No Contact Info");
name,
publicKeyBase64,
nextPubKeyHashB64: nextPubKeyHashB64,
};
await this.addContact(newContact);
} }
async addContactFromEndorserMobileLine(line: string): Promise<IndexableType> { private async addContactFromEndorserMobileLine(
line: string,
): Promise<IndexableType> {
// Note that Endorser Mobile puts name first, then did, etc. // Note that Endorser Mobile puts name first, then did, etc.
let name = line; let name = line;
let did = ""; let did = "";
@ -668,7 +641,7 @@ export default class ContactsView extends Vue {
return db.contacts.add(newContact); return db.contacts.add(newContact);
} }
async addContactFromScan(url: string): Promise<void> { private async addContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url); const payload = getContactPayloadFromJwtUrl(url);
if (!payload) { if (!payload) {
this.$notify( this.$notify(
@ -693,7 +666,7 @@ export default class ContactsView extends Vue {
} }
} }
async addContact(newContact: Contact) { private async addContact(newContact: Contact) {
if (!newContact.did) { if (!newContact.did) {
this.danger("Cannot add a contact without a DID.", "Incomplete Contact"); this.danger("Cannot add a contact without a DID.", "Incomplete Contact");
return; return;
@ -782,56 +755,30 @@ export default class ContactsView extends Vue {
}); });
} }
// prompt with confirmation if they want to delete a contact // note that this is also in DIDView.vue
confirmDeleteContact(contact: Contact) { private async confirmSetVisibility(contact: Contact, visibility: boolean) {
this.$notify( const visibilityPrompt = visibility
{ ? "Are you sure you want to make your activity visible to them?"
group: "modal", : "Are you sure you want to hide all your activity from them?";
type: "confirm",
title: "Delete",
text:
"Are you sure you want to remove " +
this.nameForDid(this.contacts, contact.did) +
" with DID " +
contact.did +
" from your contact list?",
onYes: async () => {
await this.deleteContact(contact);
},
},
-1,
);
}
async deleteContact(contact: Contact) {
await db.open();
await db.contacts.delete(contact.did);
this.contacts = R.without([contact], this.contacts);
}
// confirm to register a new contact
async confirmRegister(contact: Contact) {
this.$notify( this.$notify(
{ {
group: "modal", group: "modal",
type: "confirm", type: "confirm",
title: "Register", title: "Set Visibility",
text: text: visibilityPrompt,
"Are you sure you want to register " +
this.nameForDid(this.contacts, contact.did) +
(contact.registered
? " -- especially since they are already marked as registered"
: "") +
"?",
onYes: async () => { onYes: async () => {
await this.register(contact); const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
}, },
}, },
-1, -1,
); );
} }
async register(contact: Contact) { // note that this is also in DIDView.vue
private async register(contact: Contact) {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000); this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
try { try {
@ -896,28 +843,8 @@ export default class ContactsView extends Vue {
} }
} }
async confirmSetVisibility(contact: Contact, visibility: boolean) { // note that this is also in DIDView.vue
const visibilityPrompt = visibility private async setVisibility(
? "Are you sure you want to make your activity visible to them?"
: "Are you sure you want to hide all your activity from them?";
this.$notify(
{
group: "modal",
type: "confirm",
title: "Set Visibility",
text: visibilityPrompt,
onYes: async () => {
const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
},
},
-1,
);
}
async setVisibility(
contact: Contact, contact: Contact,
visibility: boolean, visibility: boolean,
showSuccessAlert: boolean, showSuccessAlert: boolean,
@ -966,7 +893,8 @@ export default class ContactsView extends Vue {
} }
} }
async checkVisibility(contact: Contact) { // note that this is also in DIDView.vue
private async checkVisibility(contact: Contact) {
const url = const url =
this.apiServer + this.apiServer +
"/api/report/canDidExplicitlySeeMe?did=" + "/api/report/canDidExplicitlySeeMe?did=" +
@ -999,7 +927,7 @@ export default class ContactsView extends Vue {
type: "info", type: "info",
title: "Visibility Refreshed", title: "Visibility Refreshed",
text: text:
this.nameForContact(contact, true) + libsUtil.nameForContact(contact, true) +
" can " + " can " +
(visibility ? "" : "not ") + (visibility ? "" : "not ") +
"see your activity.", "see your activity.",
@ -1033,22 +961,7 @@ export default class ContactsView extends Vue {
} }
} }
private nameForDid(contacts: Array<Contact>, did: string): string { private confirmShowGiftedDialog(giverDid: string, recipientDid: string) {
if (did === this.activeDid) {
return "you";
}
const contact = R.find((con) => con.did == did, contacts);
return this.nameForContact(contact);
}
private nameForContact(contact?: Contact, capitalize?: boolean): string {
return (
(contact?.name as string) ||
(capitalize ? "This" : "this") + " unnamed user"
);
}
confirmShowGiftedDialog(giverDid: string, recipientDid: string) {
// if they have unconfirmed amounts, ask to confirm those // if they have unconfirmed amounts, ask to confirm those
if ( if (
recipientDid === this.activeDid && recipientDid === this.activeDid &&
@ -1093,13 +1006,13 @@ export default class ContactsView extends Vue {
if (giverDid) { if (giverDid) {
giver = { giver = {
did: giverDid, did: giverDid,
name: this.nameForDid(this.contacts, giverDid), name: libsUtil.nameForDid(this.activeDid, this.contacts, giverDid),
}; };
} }
if (recipientDid) { if (recipientDid) {
receiver = { receiver = {
did: recipientDid, did: recipientDid,
name: this.nameForDid(this.contacts, recipientDid), name: libsUtil.nameForDid(this.activeDid, this.contacts, recipientDid),
}; };
} }
@ -1131,23 +1044,14 @@ export default class ContactsView extends Vue {
); );
} }
openOfferDialog(recipientDid: string) { openOfferDialog(recipientDid: string, recipientName?: string) {
(this.$refs.customOfferDialog as OfferDialog).open(recipientDid); (this.$refs.customOfferDialog as OfferDialog).open(
} recipientDid,
recipientName,
private async onClickCancelName() { );
this.contactEdit = null;
this.contactNewName = "";
}
private async onClickSaveName(contact: Contact, newName: string) {
contact.name = newName;
return db.contacts
.update(contact.did, { name: newName })
.then(() => (this.contactEdit = null));
} }
public async toggleShowContactAmounts() { private async toggleShowContactAmounts() {
const newShowValue = !this.showGiveNumbers; const newShowValue = !this.showGiveNumbers;
try { try {
await db.open(); await db.open();
@ -1183,7 +1087,7 @@ export default class ContactsView extends Vue {
this.loadGives(); this.loadGives();
} }
} }
public toggleShowGiveTotals() { private toggleShowGiveTotals() {
if (this.showGiveTotals) { if (this.showGiveTotals) {
this.showGiveTotals = false; this.showGiveTotals = false;
this.showGiveConfirmed = true; this.showGiveConfirmed = true;
@ -1196,7 +1100,7 @@ export default class ContactsView extends Vue {
} }
} }
public showGiveAmountsClassNames() { private showGiveAmountsClassNames() {
return { return {
"from-slate-400": this.showGiveTotals, "from-slate-400": this.showGiveTotals,
"to-slate-700": this.showGiveTotals, "to-slate-700": this.showGiveTotals,
@ -1206,76 +1110,31 @@ export default class ContactsView extends Vue {
"to-yellow-700": !this.showGiveTotals && !this.showGiveConfirmed, "to-yellow-700": !this.showGiveTotals && !this.showGiveConfirmed,
}; };
} }
}
</script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
/* private copySelectedContacts() {
Tooltip, generated on "title" attributes on "fa" icons if (this.contactsSelected.length === 0) {
Kudos to https://www.w3schools.com/css/css_tooltip.asp this.danger("You must select contacts to copy.");
*/ return;
/* Tooltip container */ }
.tooltip { const selectedContacts = this.contacts.filter((c) =>
position: relative; this.contactsSelected.includes(c.did),
display: inline-block; );
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */ const message =
} "To add contacts, paste this into the box on the 'People' screen.\n\n" +
/* Tooltip text */ JSON.stringify(selectedContacts, null, 2);
.tooltip .tooltiptext { useClipboard()
visibility: hidden; .copy(message)
width: 200px; .then(() => {
background-color: black; this.$notify(
color: #fff; {
text-align: center; group: "alert",
padding: 5px 0; type: "info",
border-radius: 6px; title: "Copied",
text: "Those contacts were copied to the clipboard. Have them paste it in the box on their 'People' screen.",
position: absolute; },
z-index: 1; 5000,
} );
/* How do we share with the above so code isn't duplicated? */ });
.tooltip .tooltiptext-left { }
visibility: hidden;
width: 200px;
background-color: black;
color: #fff;
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 1;
bottom: 0%;
right: 105%;
margin-left: -60px;
}
/* Show the tooltip text when you mouse over the tooltip container */
.tooltip:hover .tooltiptext {
visibility: visible;
}
.tooltip:hover .tooltiptext-left {
visibility: visible;
} }
</style> </script>

444
src/views/DIDView.vue

@ -22,12 +22,21 @@
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"> <div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div> <div>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold">
{{ {{ contact?.name || "(no name)" }}
didInfoForContact(viewingDid, activeDid, contact, allMyDids) <button
.displayName @click="
}} contactEdit = true;
contactNewName = contact.name || '';
"
title="Edit"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</h2> </h2>
<button @click="showDidDetails = !showDidDetails" class="ml-2 mr-2"> <button
@click="showDidDetails = !showDidDetails"
class="ml-2 mr-2 mt-4"
>
Details Details
<fa v-if="showDidDetails" icon="chevron-up" class="text-blue-400" /> <fa v-if="showDidDetails" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" /> <fa v-else icon="chevron-down" class="text-blue-400" />
@ -49,15 +58,76 @@
/> />
</span> </span>
</div> </div>
<div class="mt-4"> <div class="flex justify-between mt-4">
<div class="flex justify-center">Auto-Generated Icon:</div> <div class="flex items-center">
<div class="flex justify-center"> <div v-if="activeDid" class="flex justify-between">
<EntityIcon <div>
:entityId="viewingDid" <button
:iconSize="64" v-if="contact?.seesMe && contact.did !== activeDid"
class="inline-block align-middle border border-slate-300 rounded-md mr-1" class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="showLargeIdenticonId = viewingDid" @click="confirmSetVisibility(contact, false)"
/> title="They can see you"
>
<fa icon="eye" class="fa-fw" />
</button>
<button
v-else-if="!contact?.seesMe && contact?.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contact, true)"
title="They cannot see you"
>
<fa icon="eye-slash" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="eye" class="text-white mx-2.5" />
<button
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="checkVisibility(contact)"
title="Check Visibility"
v-if="contact?.did !== activeDid"
>
<fa icon="rotate" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white mx-2.5" />
</div>
<button
@click="confirmRegister(contact)"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
v-if="contact?.did !== activeDid"
title="Registration"
>
<fa
v-if="contact?.registered"
icon="person-circle-check"
class="fa-fw"
/>
<fa v-else icon="person-circle-question" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white ml-6 px-2.5" />
</div>
<button
@click="confirmDeleteContact(contact)"
class="text-sm uppercase bg-gradient-to-b from-rose-500 to-rose-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="Delete"
>
<fa icon="trash-can" class="fa-fw" />
</button>
</div>
<div v-if="!contact?.profileImageUrl">
<div>Auto-Generated Icon</div>
<div class="flex justify-center">
<EntityIcon
:entityId="viewingDid"
:iconSize="64"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
@click="showLargeIdenticonId = viewingDid"
/>
</div>
</div> </div>
</div> </div>
<div <div
@ -80,6 +150,32 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="contactEdit" class="dialog-overlay">
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Edit Name</h1>
<input
type="text"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="Name"
v-model="contactNewName"
/>
<div class="flex justify-between">
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickSaveName(contactNewName)"
>
<fa icon="save" />
</button>
<span class="inline-block w-2" />
<button
class="text-sm bg-blue-600 text-white px-2 py-1.5 rounded -ml-1.5 border-l border-blue-400"
@click="onClickCancelName()"
>
<fa icon="ban" />
</button>
</div>
</div>
</div>
<!-- Loading Animation --> <!-- Loading Animation -->
<div <div
@ -126,15 +222,16 @@
v-if="!isLoading && claims.length === 0" v-if="!isLoading && claims.length === 0"
class="flex justify-center mt-4" class="flex justify-center mt-4"
> >
<span>They Are in No Claims Visible to You</span> <span>They are in no claims visible to you.</span>
</div> </div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import * as yaml from "js-yaml";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue"; import InfiniteScroll from "@/components/InfiniteScroll.vue";
@ -152,6 +249,8 @@ import {
GenericVerifiableCredential, GenericVerifiableCredential,
GiveVerifiableCredential, GiveVerifiableCredential,
OfferVerifiableCredential, OfferVerifiableCredential,
register,
setVisibilityUtil,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
@ -174,7 +273,9 @@ export default class DIDView extends Vue {
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = []; claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
contact?: Contact; contact: Contact;
contactEdit = false;
contactNewName?: string;
contactYaml = ""; contactYaml = "";
hitEnd = false; hitEnd = false;
isLoading = false; isLoading = false;
@ -195,23 +296,29 @@ export default class DIDView extends Vue {
this.apiServer = (settings?.apiServer as string) || ""; this.apiServer = (settings?.apiServer as string) || "";
const pathParam = window.location.pathname.substring("/did/".length); const pathParam = window.location.pathname.substring("/did/".length);
let theContact: Contact | undefined;
if (pathParam) { if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam); this.viewingDid = decodeURIComponent(pathParam);
this.contact = await db.contacts.get(this.viewingDid); theContact = await db.contacts.get(this.viewingDid);
this.contactYaml = yaml.dump(this.contact); }
await this.loadClaimsAbout(); if (theContact) {
this.contact = theContact;
} else { } else {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "No claim ID was provided.", text: "No valid claim ID was provided.",
}, },
-1, -1,
); );
return;
} }
this.contactYaml = yaml.dump(this.contact);
await this.loadClaimsAbout();
await accountsDB.open(); await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray(); const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did); this.allMyDids = allAccounts.map((acc) => acc.did);
@ -227,6 +334,128 @@ export default class DIDView extends Vue {
} }
} }
// prompt with confirmation if they want to delete a contact
confirmDeleteContact(contact: Contact) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Delete",
text:
"Are you sure you want to remove " +
libsUtil.nameForContact(contact, false) +
" from your contact list?",
onYes: async () => {
await this.deleteContact(contact);
},
},
-1,
);
}
async deleteContact(contact: Contact) {
await db.open();
await db.contacts.delete(contact.did);
this.$notify(
{
group: "alert",
type: "success",
title: "Deleted",
text: "Contact has been removed.",
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
}
// confirm to register a new contact
async confirmRegister(contact: Contact) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text:
"Are you sure you want to register " +
libsUtil.nameForContact(this.contact, false) +
(contact.registered
? " -- especially since they are already marked as registered"
: "") +
"?",
onYes: async () => {
await this.register(contact);
},
},
-1,
);
}
// note that this is also in ContactView.vue
async register(contact: Contact) {
this.$notify({ group: "alert", type: "toast", title: "Sent..." }, 1000);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
await db.contacts.update(contact.did, { registered: true });
this.$notify(
{
group: "alert",
type: "success",
title: "Registration Success",
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text:
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
);
}
} catch (error) {
console.error("Error when registering:", error);
let userMessage = "There was an error. See logs for more info.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
userMessage = serverError.response.data.error.message;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
// Now set that error for the user to see.
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text: userMessage,
},
5000,
);
}
}
public async loadClaimsAbout() { public async loadClaimsAbout() {
if (!this.viewingDid) { if (!this.viewingDid) {
console.error("This should never be called without a DID."); console.error("This should never be called without a DID.");
@ -323,5 +552,178 @@ export default class DIDView extends Vue {
claimDescription(claim: GenericVerifiableCredential) { claimDescription(claim: GenericVerifiableCredential) {
return claim.claim.name || claim.claim.description || ""; return claim.claim.name || claim.claim.description || "";
} }
private async onClickCancelName() {
this.contactEdit = false;
}
private async onClickSaveName(newName: string) {
this.contact.name = newName;
return db.contacts
.update(this.contact.did, { name: newName })
.then(() => (this.contactEdit = false));
}
// note that this is also in ContactView.vue
async confirmSetVisibility(contact: Contact, visibility: boolean) {
const visibilityPrompt = visibility
? "Are you sure you want to make your activity visible to them?"
: "Are you sure you want to hide all your activity from them?";
this.$notify(
{
group: "modal",
type: "confirm",
title: "Set Visibility",
text: visibilityPrompt,
onYes: async () => {
const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
},
},
-1,
);
}
// note that this is also in ContactView.vue
async setVisibility(
contact: Contact,
visibility: boolean,
showSuccessAlert: boolean,
) {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
visibility,
);
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(
{
group: "alert",
type: "success",
title: "Visibility Set",
text:
(contact.name || "That user") +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
3000,
);
}
return true;
} else {
console.error("Got strange result from setting visibility:", result);
const message =
(result.error as string) || "Could not set visibility on the server.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Visibility",
text: message,
},
5000,
);
return false;
}
}
// note that this is also in ContactView.vue
async checkVisibility(contact: Contact) {
const url =
this.apiServer +
"/api/report/canDidExplicitlySeeMe?did=" +
encodeURIComponent(contact.did);
const headers = await getHeaders(this.activeDid);
if (!headers["Authorization"]) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Identity",
text: "There is no identity to use to check visibility.",
},
3000,
);
return;
}
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const visibility = resp.data;
contact.seesMe = visibility;
//console.log("Visi check:", visibility, contact.seesMe, contact.did);
await db.contacts.update(contact.did, { seesMe: visibility });
this.$notify(
{
group: "alert",
type: "info",
title: "Visibility Refreshed",
text:
libsUtil.nameForContact(contact, true) +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
3000,
);
} else {
console.error("Got bad server response checking visibility:", resp);
const message = resp.data.error?.message || "Got bad server response.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Checking Visibility",
text: message,
},
5000,
);
}
} catch (err) {
console.error("Caught error from request to check visibility:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Checking Visibility",
text: "Check connectivity and try again.",
},
3000,
);
}
}
} }
</script> </script>
<style>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.dialog {
background-color: white;
padding: 1rem;
border-radius: 0.5rem;
width: 100%;
max-width: 500px;
}
</style>

2
src/views/DiscoverView.vue

@ -265,6 +265,8 @@ export default class DiscoverView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) { } catch (e: any) {
console.error("Error with feed load:", e); console.error("Error with feed load:", e);
// this sometimes gives different information
console.error("Error with feed load (error added): " + e);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",

7
src/views/GiftedDetails.vue → src/views/GiftedDetailsView.vue

@ -269,6 +269,7 @@ export default class GiftedDetails extends Vue {
this.hideBackButton = this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true"; (this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || ""; this.message = ((this.$route as Router).query["message"] as string) || "";
// find any offer ID // find any offer ID
const fulfills = this.prevCredToEdit?.claim?.fulfills; const fulfills = this.prevCredToEdit?.claim?.fulfills;
const fulfillsArray = Array.isArray(fulfills) const fulfillsArray = Array.isArray(fulfills)
@ -351,6 +352,7 @@ export default class GiftedDetails extends Vue {
); );
} }
} }
// these should be functions but something's wrong with the syntax in the <> conditional
this.givenToProject = !!this.projectId; this.givenToProject = !!this.projectId;
this.givenToRecipient = !this.givenToProject && !!this.recipientDid; this.givenToRecipient = !this.givenToProject && !!this.recipientDid;
@ -549,7 +551,7 @@ export default class GiftedDetails extends Vue {
group: "alert", group: "alert",
type: "warning", type: "warning",
title: "Error", title: "Error",
text: "To assign to a project, you must open this dialog through a project.", text: "To assign to a project, you must open this page through a project.",
}, },
3000, 3000,
); );
@ -574,7 +576,7 @@ export default class GiftedDetails extends Vue {
group: "alert", group: "alert",
type: "warning", type: "warning",
title: "Error", title: "Error",
text: "To assign to a recipient, you must open this dialog from a contact.", text: "To assign to a recipient, you must open this page from a contact.",
}, },
3000, 3000,
); );
@ -694,7 +696,6 @@ export default class GiftedDetails extends Vue {
constructGiveParam() { constructGiveParam() {
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined; const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
const projectId = this.givenToProject ? this.projectId : undefined; const projectId = this.givenToProject ? this.projectId : undefined;
// const giveClaim = constructGive(
const giveClaim = hydrateGive( const giveClaim = hydrateGive(
this.prevCredToEdit?.claim as GiveVerifiableCredential, this.prevCredToEdit?.claim as GiveVerifiableCredential,
this.giverDid, this.giverDid,

4
src/views/NewEditProjectView.vue

@ -97,8 +97,8 @@
/> />
<input <input
:disabled="!startDateInput" :disabled="!startDateInput"
v-model="startTimeInput"
placeholder="Start Time" placeholder="Start Time"
v-model="startTimeInput"
type="time" type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2" class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
/> />
@ -309,7 +309,7 @@ export default class NewEditProjectView extends Vue {
return; return;
} }
try { try {
const headers = getHeaders(this.activeDid) as AxiosRequestHeaders; const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
const response = await this.axios.delete( const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER + DEFAULT_IMAGE_API_SERVER +
"/image/" + "/image/" +

633
src/views/OfferDetailsView.vue

@ -0,0 +1,633 @@
<template>
<QuickNav />
<TopMessage />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div
v-if="!hideBackButton"
class="text-lg text-center font-light relative px-7"
>
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Is Offered</h1>
<h1 class="text-xl font-bold text-center mb-4">
<span>
Offer to
{{
offeredToProject
? projectName
: offeredToRecipient
? recipientName
: "someone unidentified"
}}</span
>
</h1>
<textarea
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
placeholder="What is offered"
v-model="itemDescription"
data-testId="itemDescription"
/>
<div class="flex flex-row justify-center">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
@click="changeUnitCode()"
>
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
</span>
<div
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
@click="amountInput === '0' ? null : decrement()"
>
<fa icon="chevron-left" />
</div>
<input
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"
data-testId="inputOfferAmount"
/>
<div
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
@click="increment()"
>
<fa icon="chevron-right" />
</div>
</div>
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
Conditions
</span>
<textarea
class="w-full border border-slate-400 px-3 py-2 rounded-r"
placeholder="Prerequisites, other people to include, etc."
v-model="conditionDescription"
/>
</div>
<div class="flex flex-row mt-2">
<span
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
>
{{ validThroughDateInput ? "" : "No" }}&nbsp;Expiration
</span>
<input
v-model="validThroughDateInput"
type="date"
class="w-full rounded border border-slate-400 px-3 py-2 rounded-r"
/>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="projectId && !offeredToRecipient"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="offeredToProject"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfProject()"
/>
<label class="text-sm mt-1">
{{
projectId
? "This is offered to " + projectName
: "No project was chosen"
}}
</label>
</div>
<div class="h-7 mt-4 flex">
<input
v-if="recipientDid && !offeredToProject"
type="checkbox"
class="h-6 w-6 mr-2"
v-model="offeredToRecipient"
/>
<fa
v-else
icon="square"
class="bg-slate-500 text-slate-500 h-5 w-5 px-0.5 py-0.5 mr-2 rounded"
@click="notifyUserOfRecipient()"
/>
<label class="text-sm mt-1">
{{
recipientDid
? "This is offered to " + recipientName
: "No recipient was chosen."
}}
</label>
</div>
<div class="mt-4 flex">
<router-link
:to="{
name: 'claim-add-raw',
query: {
claim: constructOfferParam(),
},
}"
class="text-blue-500"
>
Edit & Submit Raw
</router-link>
</div>
<p class="text-center mb-2 mt-6 italic">
Sign & Send to publish to the world
<fa
icon="circle-info"
class="pl-2 text-blue-500 cursor-pointer"
@click="explainData()"
/>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
@click="confirm"
>
Sign &amp; Send
</button>
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="cancel"
>
Cancel
</button>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import {
createAndSubmitOffer,
didInfo,
editAndSubmitOffer,
GenericCredWrapper,
getPlanFromCache,
hydrateOffer,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { Contact } from "@/db/tables/contacts";
@Component({
components: {
QuickNav,
TopMessage,
},
})
export default class OfferDetailsView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
apiServer = "";
amountInput = "0";
conditionDescription = "";
itemDescription = "";
destinationPathAfter = "";
offeredToProject = false;
offeredToRecipient = false;
offererDid: string | undefined;
hideBackButton = false;
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>;
projectId = "";
projectName = "a project";
recipientDid = "";
recipientName = "";
unitCode = "HUR";
validThroughDateInput = "";
libsUtil = libsUtil;
async mounted() {
try {
this.prevCredToEdit = (this.$route as Router).query["prevCredToEdit"]
? (JSON.parse(
(this.$route as Router).query["prevCredToEdit"],
) as GenericCredWrapper<OfferVerifiableCredential>)
: undefined;
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Retrieval Error",
text: "The previous record isn't available for editing. If you submit, you'll create a new record.",
},
6000,
);
}
const prevAmount =
this.prevCredToEdit?.claim?.includesObject?.amountOfThisGood;
this.amountInput =
(this.$route as Router).query["amountInput"] ||
(prevAmount ? String(prevAmount) : "") ||
this.amountInput;
this.unitCode = ((this.$route as Router).query["unitCode"] ||
this.prevCredToEdit?.claim?.includesObject?.unitCode ||
this.unitCode) as string;
this.conditionDescription =
this.prevCredToEdit?.claim?.description || this.conditionDescription;
this.itemDescription =
(this.$route as Router).query["description"] ||
this.prevCredToEdit?.claim?.itemOffered?.description ||
this.itemDescription;
this.destinationPathAfter = (this.$route as Router).query[
"destinationPathAfter"
];
this.offererDid = ((this.$route as Router).query["offererDid"] ||
this.prevCredToEdit?.claim?.agent?.identifier ||
this.offererDid) as string;
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
// find any project ID
let project;
if (
this.prevCredToEdit?.claim?.itemOffered?.isPartOf?.["@type"] ===
"PlanAction"
) {
project = this.prevCredToEdit?.claim?.itemOffered?.isPartOf;
}
this.projectId = ((this.$route as Router).query["projectId"] ||
project?.identifier ||
this.projectId) as string;
this.projectName = ((this.$route as Router).query["projectName"] ||
project?.name ||
this.projectName) as string;
this.recipientDid = ((this.$route as Router).query["recipientDid"] ||
this.prevCredToEdit?.claim?.recipient?.identifier) as string;
this.recipientName =
((this.$route as Router).query["recipientName"] as string) || "";
this.validThroughDateInput =
this.prevCredToEdit?.claim?.validThrough || this.validThroughDateInput;
try {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
this.apiServer = settings?.apiServer || "";
this.activeDid = settings?.activeDid || "";
let allContacts: Contact[] = [];
let allMyDids: string[] = [];
if (this.recipientDid && !this.recipientName) {
allContacts = await db.contacts.toArray();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
allMyDids = allAccounts.map((acc) => acc.did);
this.recipientName = didInfo(
this.recipientDid,
this.activeDid,
allMyDids,
allContacts,
);
}
// these should be functions but something's wrong with the syntax in the <> conditional
this.offeredToProject = !!this.projectId;
this.offeredToRecipient = !this.offeredToProject && !!this.recipientDid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
console.error("Error retrieving settings from database:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: err.message || "There was an error retrieving your settings.",
},
-1,
);
}
if (this.projectId && !this.projectName) {
// console.log("Getting project name from cache", this.projectId);
const project = await getPlanFromCache(
this.projectId,
this.axios,
this.apiServer,
this.activeDid,
);
this.projectName = project?.name
? "the project: " + project.name
: "a project";
}
}
changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length];
}
increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
}
decrement() {
this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
}
cancel() {
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
cancelBack() {
(this.$router as Router).back();
}
async confirm() {
if (!this.activeDid) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "You must select an identifier before you can record a offer.",
},
2000,
);
return;
}
if (parseFloat(this.amountInput) < 0) {
this.$notify(
{
group: "alert",
type: "danger",
text: "You may not send a negative number.",
title: "",
},
2000,
);
return;
}
if (!this.itemDescription && !parseFloat(this.amountInput)) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode]
}.`,
},
2000,
);
return;
}
this.$notify(
{
group: "alert",
type: "toast",
text: "Recording the offer...",
title: "",
},
1000,
);
// this is asynchronous, but we don't need to wait for it to complete
await this.recordOffer();
}
notifyUserOfProject() {
if (!this.projectId) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a project, you must open this page through a project.",
},
3000,
);
} else {
// must be because offeredToRecipient is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a project and to a recipient.",
},
3000,
);
}
}
notifyUserOfRecipient() {
if (!this.recipientDid) {
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "To assign to a recipient, you must open this page from a contact.",
},
3000,
);
} else {
// must be because offeredToProject is true
this.$notify(
{
group: "alert",
type: "warning",
title: "Error",
text: "You cannot assign both to a recipient and to a project.",
},
3000,
);
}
}
/**
*
* @param offererDid may be null
* @param description may be an empty string
* @param amountInput may be 0
* @param unitCode may be omitted, defaults to "HUR"
*/
public async recordOffer() {
try {
const recipientDid = this.offeredToRecipient
? this.recipientDid
: undefined;
const projectId = this.offeredToProject ? this.projectId : undefined;
let result;
if (this.prevCredToEdit) {
// don't create from a blank one in case some properties were set from a different interface
result = await editAndSubmitOffer(
this.axios,
this.apiServer,
this.prevCredToEdit,
this.activeDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
this.validThroughDateInput,
recipientDid,
projectId,
);
} else {
result = await createAndSubmitOffer(
this.axios,
this.apiServer,
this.activeDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
this.validThroughDateInput,
recipientDid,
projectId,
);
}
if (result.type === "error" || this.isCreationError(result.response)) {
const errorMessage = this.getCreationErrorMessage(result);
console.error("Error with offer creation result:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage || "There was an error creating the offer.",
},
-1,
);
} else {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: `That offer was recorded.`,
},
5000,
);
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
(this.$router as Router).back();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error with offer recordation caught:", error);
const errorMessage =
error.userMessage ||
error.response?.data?.error?.message ||
"There was an error recording the offer.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
-1,
);
}
}
constructOfferParam() {
const recipientDid = this.offeredToRecipient
? this.recipientDid
: undefined;
const projectId = this.offeredToProject ? this.projectId : undefined;
const offerClaim = hydrateOffer(
this.prevCredToEdit?.claim as OfferVerifiableCredential,
this.activeDid,
recipientDid,
this.itemDescription,
parseFloat(this.amountInput),
this.unitCode,
this.conditionDescription,
projectId,
this.validThroughDateInput,
this.prevCredToEdit?.id as string,
);
const claimStr = JSON.stringify(offerClaim);
return claimStr;
}
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @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
getCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{
group: "alert",
type: "success",
title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE,
},
-1,
);
}
}
</script>

7
src/views/ProjectViewView.vue

@ -162,6 +162,7 @@
<div v-if="activeDid && isRegistered" class="mt-4"> <div v-if="activeDid && isRegistered" class="mt-4">
<div class="text-center"> <div class="text-center">
<button <button
data-testId="offerButton"
@click="openOfferDialog()" @click="openOfferDialog()"
class="block w-full text-lg font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md" class="block w-full text-lg font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
> >
@ -169,7 +170,11 @@
</button> </button>
</div> </div>
</div> </div>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" /> <OfferDialog
ref="customOfferDialog"
:projectId="this.projectId"
:projectName="this.name"
/>
<div v-if="activeDid && isRegistered"> <div v-if="activeDid && isRegistered">
<div class="text-center"> <div class="text-center">

70
src/views/ProjectsView.vue

@ -109,6 +109,19 @@
</div> </div>
<div> <div>
<div>
To
{{
offer.fulfillsPlanHandleId
? projectNameFromHandleId[offer.fulfillsPlanHandleId]
: didInfo(
offer.recipientDid,
activeDid,
allMyDids,
allContacts,
)
}}
</div>
<div> <div>
{{ offer.objectDescription }} {{ offer.objectDescription }}
</div> </div>
@ -244,11 +257,14 @@ import QuickNav from "@/components/QuickNav.vue";
import ProjectIcon from "@/components/ProjectIcon.vue"; import ProjectIcon from "@/components/ProjectIcon.vue";
import TopMessage from "@/components/TopMessage.vue"; import TopMessage from "@/components/TopMessage.vue";
import { import {
didInfo,
getHeaders, getHeaders,
getPlanFromCache,
OfferSummaryRecord, OfferSummaryRecord,
PlanData, PlanData,
} from "@/libs/endorserServer"; } from "@/libs/endorserServer";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
import { Contact } from "@/db/tables/contacts";
@Component({ @Component({
components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage }, components: { EntityIcon, InfiniteScroll, QuickNav, ProjectIcon, TopMessage },
@ -263,16 +279,19 @@ export default class ProjectsView extends Vue {
} }
activeDid = ""; activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
projects: PlanData[] = []; projects: PlanData[] = [];
isLoading = false; isLoading = false;
isRegistered = false; isRegistered = false;
numAccounts = 0;
offers: OfferSummaryRecord[] = []; offers: OfferSummaryRecord[] = [];
projectNameFromHandleId: Record<string, string> = {}; // mapping from handleId to description
showOffers = true; showOffers = true;
showProjects = false; showProjects = false;
libsUtil = libsUtil; libsUtil = libsUtil;
didInfo = didInfo;
async mounted() { async mounted() {
try { try {
@ -282,9 +301,13 @@ export default class ProjectsView extends Vue {
this.apiServer = (settings?.apiServer as string) || ""; this.apiServer = (settings?.apiServer as string) || "";
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
this.allContacts = await db.contacts.toArray();
await accountsDB.open(); await accountsDB.open();
this.numAccounts = await accountsDB.accounts.count(); const allAccounts = await accountsDB.accounts.toArray();
if (this.numAccounts === 0) { this.allMyDids = allAccounts.map((acc) => acc.did);
if (allAccounts.length === 0) {
console.error("No accounts found."); console.error("No accounts found.");
this.errNote("You need an identifier to load your projects."); this.errNote("You need an identifier to load your projects.");
} else { } else {
@ -343,10 +366,7 @@ export default class ProjectsView extends Vue {
async loadMoreProjectData(payload: boolean) { async loadMoreProjectData(payload: boolean) {
if (this.projects.length > 0 && payload) { if (this.projects.length > 0 && payload) {
const latestProject = this.projects[this.projects.length - 1]; const latestProject = this.projects[this.projects.length - 1];
await this.loadProjects( await this.loadProjects(`beforeId=${latestProject.rowid}`);
this.activeDid,
`beforeId=${latestProject.rowid}`,
);
} }
} }
@ -355,7 +375,7 @@ export default class ProjectsView extends Vue {
* @param issuerDid of the user * @param issuerDid of the user
* @param urlExtra additional url parameters in a string * @param urlExtra additional url parameters in a string
**/ **/
async loadProjects(activeDid?: string, urlExtra: string = "") { async loadProjects(urlExtra: string = "") {
const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`; const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`;
await this.projectDataLoader(url); await this.projectDataLoader(url);
} }
@ -396,13 +416,37 @@ export default class ProjectsView extends Vue {
* @param token Authorization token * @param token Authorization token
**/ **/
async offerDataLoader(url: string) { async offerDataLoader(url: string) {
const headers = getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
try { try {
this.isLoading = true; this.isLoading = true;
const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); const resp = await this.axios.get(url, { headers } as AxiosRequestConfig);
if (resp.status === 200 && resp.data.data) { if (resp.status === 200 && resp.data.data) {
this.offers = this.offers.concat(resp.data.data); // add one-by-one as they retrieve project names, potentially from the server
for (const offer of resp.data.data) {
if (offer.fulfillsPlanHandleId) {
const project = await getPlanFromCache(
offer.fulfillsPlanHandleId,
this.axios,
this.apiServer,
this.activeDid,
);
const projectName = project?.name as string;
console.log(
"now have name for",
offer.fulfillsPlanHandleId,
projectName,
);
this.projectNameFromHandleId[offer.fulfillsPlanHandleId] =
projectName;
console.log(
"now have a real name for",
offer.fulfillsPlanHandleId,
this.projectNameFromHandleId[offer.fulfillsPlanHandleId],
);
}
this.offers = this.offers.concat([offer]);
}
} else { } else {
console.error( console.error(
"Bad server response & data for offers:", "Bad server response & data for offers:",
@ -443,7 +487,7 @@ export default class ProjectsView extends Vue {
async loadMoreOfferData(payload: boolean) { async loadMoreOfferData(payload: boolean) {
if (this.offers.length > 0 && payload) { if (this.offers.length > 0 && payload) {
const latestOffer = this.offers[this.offers.length - 1]; const latestOffer = this.offers[this.offers.length - 1];
await this.loadOffers(this.activeDid, `&beforeId=${latestOffer.jwtId}`); await this.loadOffers(`&beforeId=${latestOffer.jwtId}`);
} }
} }
@ -452,8 +496,8 @@ export default class ProjectsView extends Vue {
* @param issuerDid of the user * @param issuerDid of the user
* @param urlExtra additional url parameters in a string * @param urlExtra additional url parameters in a string
**/ **/
async loadOffers(issuerDid?: string, urlExtra: string = "") { async loadOffers(urlExtra: string = "") {
const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${issuerDid}${urlExtra}`; const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${this.activeDid}${urlExtra}`;
await this.offerDataLoader(url); await this.offerDataLoader(url);
} }

13
src/views/SharedPhotoView.vue

@ -48,6 +48,17 @@
</div> </div>
<div v-else class="text-center mb-4"> <div v-else class="text-center mb-4">
<p>No image found.</p> <p>No image found.</p>
<p class="mt-4">
If you shared an image, the cause is usually that you do not have the
recent version of this app, or that the app has not refreshed the
service code underneath. To fix this, first make sure you have latest
version by comparing your version at the bottom of "Help" with the
version at the bottom of https://timesafari.app/help in a browser. After
that, it may eventually work, but you can speed up the process by
clearing your data cache (in the browser on mobile, even if you
installed it) and/or reinstalling the app (after backing up all your
data, of course).
</p>
</div> </div>
</section> </section>
</template> </template>
@ -122,7 +133,7 @@ export default class SharedPhotoView extends Vue {
name: "gifted-details", name: "gifted-details",
// this might be wrong since "name" goes with params, but it works so test well when you change it // this might be wrong since "name" goes with params, but it works so test well when you change it
query: { query: {
destinationPathAfter: "/home", destinationPathAfter: "/",
hideBackButton: true, hideBackButton: true,
imageUrl: url, imageUrl: url,
recipientDid: this.activeDid, recipientDid: this.activeDid,

15
sw_scripts/safari-notifications.js

@ -566,14 +566,27 @@ async function getNotificationCount() {
return result; return result;
} }
async function blobToBase64String(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result); // potential problem if it returns an ArrayBuffer?
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// Store the image blob and go immediate to a page to upload it. // Store the image blob and go immediate to a page to upload it.
// @param photo - image Blob to store for later retrieval after redirect // @param photo - image Blob to store for later retrieval after redirect
async function savePhoto(photo) { async function savePhoto(photo) {
try { try {
const photoBase64 = await blobToBase64String(photo);
const db = await openIndexedDB("TimeSafari"); const db = await openIndexedDB("TimeSafari");
const transaction = db.transaction("temp", "readwrite"); const transaction = db.transaction("temp", "readwrite");
const store = transaction.objectStore("temp"); const store = transaction.objectStore("temp");
await updateRecord(store, { id: "shared-photo", blob: photo }); await updateRecord(store, {
id: "shared-photo-base64",
blobB64: photoBase64,
});
transaction.oncomplete = () => db.close(); transaction.oncomplete = () => db.close();
} catch (error) { } catch (error) {
console.error("safari-notifications logMessage IndexedDB error", error); console.error("safari-notifications logMessage IndexedDB error", error);

6
test-playwright/20-create-project.spec.ts

@ -12,8 +12,8 @@ test('Create new project, then search for it', async ({ page }) => {
const finalRandomString = randomString.substring(0, 16); const finalRandomString = randomString.substring(0, 16);
// Standard texts // Standard texts
const standardTitle = "Idea "; const standardTitle = 'Idea ';
const standardDescription = "Description of Idea "; const standardDescription = 'Description of Idea ';
// Combine texts with the random string // Combine texts with the random string
const finalTitle = standardTitle + finalRandomString; const finalTitle = standardTitle + finalRandomString;
@ -43,12 +43,10 @@ test('Create new project, then search for it', async ({ page }) => {
// Search for newly-created project in /projects // Search for newly-created project in /projects
await page.goto('./projects'); await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click(); await page.getByRole('link', { name: 'Projects', exact: true }).click();
await page.waitForTimeout(3000); // Wait for a bit
await expect(page.locator('ul#listProjects li.border-b:nth-child(1)')).toContainText(finalRandomString); // Assumes newest project always appears first in the Projects tab list await expect(page.locator('ul#listProjects li.border-b:nth-child(1)')).toContainText(finalRandomString); // Assumes newest project always appears first in the Projects tab list
// Search for newly-created project in /discover // Search for newly-created project in /discover
await page.goto('./discover'); await page.goto('./discover');
await page.waitForTimeout(3000); // Wait for a bit
await page.getByPlaceholder('Search…').fill(finalRandomString); await page.getByPlaceholder('Search…').fill(finalRandomString);
await page.locator('#QuickSearch button').click(); await page.locator('#QuickSearch button').click();
await expect(page.locator('ul#listDiscoverResults li.border-b:nth-child(1)')).toContainText(finalRandomString); await expect(page.locator('ul#listDiscoverResults li.border-b:nth-child(1)')).toContainText(finalRandomString);

16
test-playwright/30-record-gift.spec.ts

@ -2,23 +2,17 @@ import { test, expect } from '@playwright/test';
import { importUser } from './testUtils'; import { importUser } from './testUtils';
test('Record something given', async ({ page }) => { test('Record something given', async ({ page }) => {
// Generate a random string of 16 characters // Generate a random string of a few characters
let randomString = Math.random().toString(36).substring(2, 18); const randomString = Math.random().toString(36).substring(2, 6);
// In case the string is shorter than 16 characters, generate more characters until it is 16 characters long
while (randomString.length < 16) {
randomString += Math.random().toString(36).substring(2, 18);
}
const finalRandomString = randomString.substring(0, 16);
// Generate a random non-zero single-digit number // Generate a random non-zero single-digit number
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1; const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
// Standard title prefix // Standard title prefix
const standardTitle = "Gift "; const standardTitle = 'Gift ';
// Combine title prefix with the random string // Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString; const finalTitle = standardTitle + randomString;
// Import user 00 // Import user 00
await importUser(page, '00'); await importUser(page, '00');
@ -27,7 +21,7 @@ test('Record something given', async ({ page }) => {
await page.goto('./'); await page.goto('./');
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click(); await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click();
await page.getByPlaceholder('What was given').fill(finalTitle); await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton', { id: 'inputGivenAmount' }).fill(randomNonZeroNumber.toString()); await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click(); await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible(); await expect(page.getByText('That gift was recorded.')).toBeVisible();

41
test-playwright/35-record-gift-from-image-share.spec.ts

@ -1,5 +1,6 @@
import path from 'path'; import path from 'path';
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record item given from image-share', async ({ page }) => { test('Record item given from image-share', async ({ page }) => {
@ -8,11 +9,7 @@ test('Record item given from image-share', async ({ page }) => {
// Combine title prefix with the random string // Combine title prefix with the random string
const finalTitle = `Gift ${randomString} from image-share`; const finalTitle = `Gift ${randomString} from image-share`;
// Create new ID using seed phrase "rigid shrug mobile…" await importUser(page, '00');
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
// Record something given // Record something given
await page.goto('./test'); await page.goto('./test');
@ -37,4 +34,36 @@ test('Record item given from image-share', async ({ page }) => {
await page.goto('./'); await page.goto('./');
const item1 = page.locator('li').filter({ hasText: finalTitle }); const item1 = page.locator('li').filter({ hasText: finalTitle });
await expect(item1.getByRole('img')).toBeVisible(); await expect(item1.getByRole('img')).toBeVisible();
}); });
// // I believe there's a way to test this service worker feature.
// // The following is what I got from ChatGPT. I wonder if it doesn't work because it's not registering the service worker correctly.
//
// test('Trigger a photo-sharing fetch event in service worker with POST to /share-target', async ({ page }) => {
// await importUser(page, '00');
//
// // Create a FormData object with a photo
// const photoPath = path.join(__dirname, '..', 'public', 'img', 'icons', 'android-chrome-192x192.png');
// const photoContent = await fs.readFileSync(photoPath);
// const [response] = await Promise.all([
// page.waitForResponse(response => response.url().includes('/share-target')), // also check for response.status() === 303 ?
// page.evaluate(async (photoContent) => {
// const formData = new FormData();
// formData.append('photo', new Blob([photoContent], { type: 'image/png' }), 'test-photo.jpg');
//
// const response = await fetch('/share-target', {
// method: 'POST',
// body: formData,
// });
//
// return response;
// }, photoContent)
// ]);
//
// // Verify the response redirected to /shared-photo
// //expect(response.status).toBe(303);
// console.log('response headers', response.headers());
// console.log('response status', response.status());
// console.log('response url', response.url());
// expect(response.url()).toContain('/shared-photo');
// });

13
test-playwright/40-add-contact.spec.ts

@ -15,20 +15,20 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1; const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
// Standard title prefix // Standard title prefix
const standardTitle = "Gift "; const standardTitle = 'Gift ';
// Combine title prefix with the random string // Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString; const finalTitle = standardTitle + finalRandomString;
// Contact name // Contact name
const contactName = 'Contact 00'; const contactName = 'Contact #000';
// Import user 01 // Import user 01
await importUser(page, '01'); await importUser(page, '01');
// Add new contact 00 // Add new contact 00
await page.goto('./contacts'); await page.goto('./contacts');
await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F'); await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F, User #000');
await page.locator('button > svg.fa-plus').click(); await page.locator('button > svg.fa-plus').click();
await expect(page.locator('div[role="alert"]')).toBeVisible(); await expect(page.locator('div[role="alert"]')).toBeVisible();
@ -36,10 +36,11 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// await page.locator('div[role="alert"] button:has-text("Yes")').click(); // await page.locator('div[role="alert"] button:has-text("Yes")').click();
// Verify added contact // Verify added contact
await expect(page.locator('li.border-b')).toContainText('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F'); await expect(page.locator('li.border-b')).toContainText('User #000');
// Rename contact // Rename contact
await page.locator('li.border-b h2 > button[title="Edit"]').click(); await page.locator('li.border-b div div > a[title="See more about this person"]').click();
await page.locator('h2 > button[title="Edit"]').click();
await expect(page.locator('div.dialog-overlay > div.dialog').filter({ hasText: 'Edit Name' })).toBeVisible(); await expect(page.locator('div.dialog-overlay > div.dialog').filter({ hasText: 'Edit Name' })).toBeVisible();
await page.getByPlaceholder('Name', { exact: true }).fill(contactName); await page.getByPlaceholder('Name', { exact: true }).fill(contactName);
await page.locator('.dialog > .flex > button').first().click(); await page.locator('.dialog > .flex > button').first().click();
@ -82,4 +83,4 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Refresh claim page, Confirm button should be hidden // Refresh claim page, Confirm button should be hidden
await page.reload(); await page.reload();
await expect(page.getByRole('button', { name: 'Confirm' })).toBeHidden(); await expect(page.getByRole('button', { name: 'Confirm' })).toBeHidden();
}); });

63
test-playwright/50-record-offer.spec.ts

@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record an offer', async ({ page }) => {
// Generate a random string of 3 characters, skipping the "0." at the beginning
const randomString = Math.random().toString(36).substring(2, 5);
// Standard title prefix
const description = `Offering of ${randomString}`;
const updatedDescription = `Updated ${description}`;
const randomNonZeroNumber = Math.floor(Math.random() * 998) + 1;
// Create new ID for default user
await importUser(page);
// Select a project
await page.goto('./discover');
await page.locator('ul#listDiscoverResults li:nth-child(1)').click();
// Record an offer
await page.getByTestId('offerButton').click();
await page.getByTestId('inputDescription').fill(description);
await page.getByTestId('inputOfferAmount').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That offer was recorded.')).toBeVisible();
// go to the offer and check the values
await page.goto('./projects');
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(description, { exact: true })).toBeVisible();
const serverPagePromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'View on the Public Server' }).click();
const serverPage = await serverPagePromise;
await serverPage.getByText(description);
await serverPage.getByText('did:none:HIDDEN');
// Now update that offer
// find the edit page and check the old values again
await page.goto('./projects');
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
await page.getByTestId('editClaimButton').click();
await page.locator('heading', { hasText: 'What is offered' }).isVisible();
const itemDesc = await page.getByTestId('itemDescription');
await expect(itemDesc).toHaveValue(description);
const amount = await page.getByTestId('inputOfferAmount');
await expect(amount).toHaveValue(randomNonZeroNumber.toString());
// update the values
await itemDesc.fill(updatedDescription);
await amount.fill(String(randomNonZeroNumber + 1));
await page.getByRole('button', { name: 'Sign & Send' }).click();
// go to the offer claim again and check the updated values
await page.goto('./projects');
await page.locator('li').filter({ hasText: description }).locator('a').first().click();
const newItemDesc = await page.getByTestId('description');
await expect(newItemDesc).toHaveText(updatedDescription);
// go to edit page
await page.getByTestId('editClaimButton').click();
const newAmount = await page.getByTestId('inputOfferAmount');
await expect(newAmount).toHaveValue((randomNonZeroNumber + 1).toString());
});

6
test-playwright/testUtils.js → test-playwright/testUtils.ts

@ -1,6 +1,6 @@
import { expect } from '@playwright/test'; import { expect, Page } from '@playwright/test';
export async function importUser(page, id) { export async function importUser(page: Page, id?: string): Promise<void> {
let seedPhrase, userName, did; let seedPhrase, userName, did;
// Set seed phrase and DID based on user ID // Set seed phrase and DID based on user ID
@ -21,6 +21,8 @@ export async function importUser(page, id) {
await page.getByText('You have a seed').click(); await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').fill(seedPhrase); await page.getByPlaceholder('Seed Phrase').fill(seedPhrase);
await page.getByRole('button', { name: 'Import' }).click(); await page.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sectionUsageLimits')).toContainText('You have done');
await expect(page.locator('#sectionUsageLimits')).toContainText('You have uploaded');
// Set name // Set name
await page.getByRole('link', { name: 'Set Your Name' }).click(); await page.getByRole('link', { name: 'Set Your Name' }).click();

2
vite.config.mjs

@ -16,7 +16,7 @@ export default defineConfig({
srcDir: '.', srcDir: '.',
filename: 'sw_scripts-combined.js', filename: 'sw_scripts-combined.js',
manifest: { manifest: {
// This is used for the app name. It doesn't include a space, because iOS complains if i recall correctly. // This is used for the app name. It doesn't include a space, because iOS complains if I recall correctly.
// There is a name with spaces in the constants/app.js file for use internally. // There is a name with spaces in the constants/app.js file for use internally.
name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name, name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,
short_name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name, short_name: process.env.TIME_SAFARI_APP_TITLE || require('./package.json').name,

Loading…
Cancel
Save