Compare commits

...

26 Commits

Author SHA1 Message Date
Jose Olarte III d679d0c804 Merge branch 'master' into test-playwright 3 months ago
Trent Larson 7cba232e44 fix tests 3 months ago
Trent Larson c95b2178ef copy a list of contacts and then import 3 months ago
Trent Larson 511be5f9a2 move contact actions into the details page (prepping for checkboxes) 3 months ago
Trent Larson 8b4f46d07b bump verison and add "-beta" 3 months ago
Trent Larson 4064eb75a9 bump to version 0.3.20 3 months ago
Trent Larson d96aa01107 update bad verbiage on offer page, fix offer test 3 months ago
Trent Larson 6e89271616 bump verison and add "-beta" 3 months ago
Trent Larson ee9c14942c bump to version 0.3.19 3 months ago
Trent Larson a8bb1b46c2 fix error editing an offer, tweak tests to fix red in IntelliJ 3 months ago
Trent Larson a8b82037b9 bump to version 0.3.18 3 months ago
trentlarson 5811dacb84 Merge pull request 'offer editing' (#123) from offer-edit into master 3 months ago
Trent Larson 1a4052d1a0 fix tests, add test for offer update 3 months ago
Trent Larson a9b12f4d7c allow editing of an offer 3 months ago
Trent Larson 269d00a096 start with offer-edit 3 months ago
Trent Larson 05f898d462 put BTC before BX in unit rotation 3 months ago
Trent Larson 2c2c95a824 fix destination page after photo is shared 3 months ago
Trent Larson 4244e6b279 add recipient description to offers in user's list 3 months ago
Trent Larson 56e3440875 misc commentary 3 months ago
Trent Larson 1fe540d5a8 fix list of offers (and some other lists), and add tests for offers 3 months ago
Trent Larson 089d4f0733 change back the check for adding a service worker because tests would get constant errors 3 months ago
Trent Larson da79d581b7 bump version and add "-beta" 3 months ago
Trent Larson cefa384ff1 bump to version 0.3.17 3 months ago
Trent Larson 5849ae2de4 remove "export" that's not available in raw JS 3 months ago
Trent Larson 60ed21c0d9 fix image shared with web share 3 months ago
Trent Larson 3f77f9b3ff record some info on my attempt to test a service worker 3 months ago
  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. 45
      src/components/OfferDialog.vue
  9. 4
      src/db/tables/contacts.ts
  10. 175
      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. 48
      src/views/ClaimView.vue
  16. 2
      src/views/ContactAmountsView.vue
  17. 4
      src/views/ContactQRScanShowView.vue
  18. 537
      src/views/ContactsView.vue
  19. 430
      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. 39
      test-playwright/35-record-gift-from-image-share.spec.ts
  31. 11
      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).
## [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
- Photos on more screens
### Fixed

9
CONTRIBUTING.md

@ -2,5 +2,10 @@
Welcome! We are happy to have your help with this project.
Note that all contributions will be under our
[license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
We expect contributions to include automated tests and pass linting. Run the `test-all` task.
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`.
* Commit everything (since the commit hash is used the app).
* Record what version is currently on production.
* Run the correct build:

4
package-lock.json

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

2
package.json

@ -1,6 +1,6 @@
{
"name": "TimeSafari",
"version": "0.3.17-beta",
"version": "0.3.21-beta",
"scripts": {
"dev": "vite",
"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
//timeout: 7000,
timeout: 20000,
/* 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.
* 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.
*/
webServer: {

2
src/components/GiftedDialog.vue

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

45
src/components/OfferDialog.vue

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

175
src/libs/endorserServer.ts

@ -48,7 +48,7 @@ export interface ClaimResult {
}
export interface GenericVerifiableCredential {
"@context"?: string;
"@context"?: string; // optional when embedded, eg. in an Agree
"@type": string;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
@ -62,6 +62,7 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
id: string;
issuedAt: string;
issuer: string;
publicUrls?: Record<string, string>; // only for IDs that want to be public
}
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{
@ -139,13 +140,13 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential {
"@context"?: string; // optional when embedded, eg. in an Agree
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; // optional when embedded... though it doesn't make sense to agree to an offer
"@type": "Offer";
description?: string;
description?: string; // conditions for the offer
includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: {
description?: string;
description?: string; // description of the item
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
};
offeredBy?: { identifier: string };
@ -155,7 +156,7 @@ export interface OfferVerifiableCredential {
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential {
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
"@context": "https://schema.org";
"@type": "PlanAction";
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
const HIDDEN_DID = "did:none:HIDDEN";
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
export function isDid(did: string) {
return did.startsWith("did:");
}
@ -507,6 +504,10 @@ export async function getHeaders(did?: string) {
return headers;
}
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
max: 500,
});
/**
* @param handleId nullable, in which case "undefined" will be returned
* @param requesterDid optional, in which case no private info will be returned
@ -563,6 +564,8 @@ export async function setPlanInCache(
/**
* Construct GiveAction VC for submission to server
*
* @param lastClaimId supplied when editing a previous claim
*/
export function hydrateGive(
vcClaimOrig?: GiveVerifiableCredential,
@ -587,6 +590,7 @@ export function hydrateGive(
};
if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier;
}
@ -594,7 +598,8 @@ export function hydrateGive(
vcClaim.agent = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
vcClaim.description = description || undefined;
vcClaim.object = amount
vcClaim.object =
amount && !isNaN(amount)
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
@ -603,7 +608,7 @@ export function hydrateGive(
vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
}
// ... 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(
(elem) => elem["@type"] !== "PlanAction",
);
@ -639,8 +644,8 @@ export function hydrateGive(
*
* @param fromDid may be null
* @param toDid
* @param description may be null; should have this or amount
* @param amount may be null; should have this or description
* @param description may be null
* @param amount may be null
*/
export async function createAndSubmitGive(
axios: Axios,
@ -667,6 +672,7 @@ export async function createAndSubmitGive(
fulfillsOfferHandleId,
isTrade,
imageUrl,
undefined,
);
return createAndSubmitClaim(
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
*
* @param fromDid may be null
* @param toDid
* @param description may be null; should have this or amount
* @param amount may be null; should have this or description
* @param toDid may be null if project is provided
* @param description may be null
* @param amount may be null
*/
export async function editAndSubmitGive(
axios: Axios,
@ -721,50 +727,127 @@ export async function editAndSubmitGive(
}
/**
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
* Construct Offer VC for submission to server
*
* @param identity
* @param description may be null; should have this or amount
* @param amount may be null; should have this or description
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null)
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
* @param lastClaimId supplied when editing a previous claim
*/
export async function createAndSubmitOffer(
axios: Axios,
apiServer: string,
issuerDid: string,
description?: string,
export function hydrateOffer(
vcClaimOrig?: OfferVerifiableCredential,
fromDid?: string,
toDid?: string,
itemDescription?: string,
amount?: number,
unitCode?: string,
expirationDate?: string,
recipientDid?: string,
conditionDescription?: string,
fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim: OfferVerifiableCredential = {
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",
offeredBy: { identifier: issuerDid },
validThrough: expirationDate || undefined,
};
if (amount) {
vcClaim.includesObject = {
amountOfThisGood: amount,
unitCode: unitCode || "HUR",
};
if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier;
}
if (description) {
vcClaim.itemOffered = { description };
}
if (recipientDid) {
vcClaim.recipient = { identifier: recipientDid };
}
if (fulfillsProjectHandleId) {
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
*
* @param identity
* @param description may be null
* @param amount 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)
*/
export async function createAndSubmitOffer(
axios: Axios,
apiServer: string,
issuerDid: string,
itemDescription: string,
amount?: number,
unitCode?: string,
conditionDescription?: string,
validThrough?: string,
recipientDid?: string,
fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
const vcClaim = hydrateOffer(
undefined,
issuerDid,
recipientDid,
itemDescription,
amount,
unitCode,
conditionDescription,
fulfillsProjectHandleId,
validThrough,
undefined,
);
return createAndSubmitClaim(
vcClaim as OfferVerifiableCredential,
issuerDid,
apiServer,
axios,
);
}
export async function editAndSubmitOffer(
axios: Axios,
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(
vcClaim as OfferVerifiableCredential,
issuerDid,

33
src/libs/util.ts

@ -1,11 +1,14 @@
// many of these are also found in endorser-mobile utility.ts
import axios, { AxiosResponse } from "axios";
import { Buffer } from "buffer";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
MASTER_SETTINGS_KEY,
@ -18,11 +21,9 @@ import {
OfferVerifiableCredential,
} 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 { createPeerDid } from "@/libs/crypto/vc/didPeer";
import { registerCredential } from "@/libs/crypto/vc/passkeyDidPeer";
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.";
@ -30,8 +31,8 @@ export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
/* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = {
"BX": "BX",
"BTC": "BTC",
"BX": "BX",
"ETH": "ETH",
"HUR": "Hours",
"USD": "US $",
@ -40,8 +41,8 @@ export const UNIT_SHORT: Record<string, string> = {
/* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = {
"BX": "Buxbe",
"BTC": "Bitcoin",
"BX": "Buxbe",
"ETH": "Ethereum",
"HUR": "hours",
"USD": "dollars",
@ -91,6 +92,28 @@ export const isGiveAction = (
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) => {
fn();
useClipboard()

1
src/registerServiceWorker.ts

@ -2,6 +2,7 @@
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") {
register("/sw_scripts-combined.js", {
ready() {

10
src/router/index.ts

@ -91,7 +91,7 @@ const routes: Array<RouteRecordRaw> = [
{
path: "/gifted-details",
name: "gifted-details",
component: () => import("../views/GiftedDetails.vue"),
component: () => import("@/views/GiftedDetailsView.vue"),
},
{
path: "/help",
@ -143,6 +143,11 @@ const routes: Array<RouteRecordRaw> = [
name: "new-identifier",
component: () => import("../views/NewIdentifierView.vue"),
},
{
path: "/offer-details/:id?",
name: "offer-details",
component: () => import("../views/OfferDetailsView.vue"),
},
{
path: "/project/:id?",
name: "project",
@ -189,6 +194,9 @@ const routes: Array<RouteRecordRaw> = [
name: "shared-photo",
component: () => import("@/views/SharedPhotoView.vue"),
},
// /share-target is also an endpoint in the service worker
{
path: "/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.
*/
} catch (error) {
// this can happen when running automated tests in dev mode because notifications don't work
console.error(
"Telling user to clear cache at page create because:",
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(
{
group: "alert",
@ -1282,17 +1288,6 @@ export default class AccountViewView extends Vue {
}
} catch (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;

48
src/views/ClaimView.vue

@ -24,13 +24,15 @@
{{ capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType) }}
<button
v-if="
veriClaim.claimType === 'GiveAction' &&
veriClaim.issuer === activeDid
['GiveAction', 'Offer'].includes(
veriClaim.claimType as string,
) && veriClaim.issuer === activeDid
"
@click="onClickEditClaim"
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>
</h2>
<div class="text-sm">
@ -49,9 +51,12 @@
</button>
<span v-show="showIdCopy">Copied ID</span>
</div>
<div>
<div data-testId="description">
<fa icon="message" class="fa-fw text-slate-400" />
{{ veriClaim.claim?.description }}
{{
veriClaim.claim?.itemOffered?.description ||
veriClaim.claim?.description
}}
</div>
<div>
<fa icon="user" class="fa-fw text-slate-400" />
@ -399,7 +404,7 @@
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
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
>
</div>
@ -422,7 +427,10 @@
</button>
</div>
<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>
<a
@ -840,6 +848,7 @@ export default class ClaimView extends Vue {
}
onClickEditClaim() {
if (this.veriClaim.claimType === "GiveAction") {
const route = {
name: "gifted-details",
query: {
@ -849,6 +858,31 @@ export default class ClaimView extends Vue {
},
};
(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>

2
src/views/ContactAmountsView.vue

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

537
src/views/ContactsView.vue

@ -37,7 +37,35 @@
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
@click="onClickNewContact()"
>
<fa icon="plus" class="fa-fw"></fa>
<fa icon="plus" class="fa-fw" />
</button>
</div>
<div class="flex justify-between">
<div class="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 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>
@ -50,6 +78,7 @@
{{ showGiveNumbers ? "Hide Given Hours" : "Show Given Hours" }}
</button>
</div>
</div>
<div class="flex justify-between mt-1" v-if="showGiveNumbers">
<div class="w-full text-right">
In the following, only the most recent hours are included. To see more,
@ -82,147 +111,51 @@
<ul
id="listContacts"
v-if="contacts.length > 0"
class="border-t border-slate-300"
class="border-t border-slate-300 mt-1"
>
<li
class="border-b border-slate-300 pt-2.5 pb-4"
v-for="contact in contacts"
class="border-b border-slate-300 pt-1 pb-1"
v-for="contact in filteredContacts()"
:key="contact.did"
>
<div class="grow overflow-hidden">
<h2 class="text-base font-semibold">
<div class="flex items-center">
<EntityIcon
:contact="contact"
:iconSize="24"
class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer"
@click="showLargeIdenticon = contact"
/>
{{ contact.name || AppString.NO_CONTACT_NAME }}
<button
<input
type="checkbox"
v-if="!showGiveNumbers"
:checked="contactsSelected.includes(contact.did)"
@click="
contactEdit = contact;
contactNewName = contact.name || '';
contactsSelected.includes(contact.did)
? contactsSelected.splice(
contactsSelected.indexOf(contact.did),
1,
)
: contactsSelected.push(contact.did)
"
title="Edit"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1"></fa>
</button>
class="ml-2 h-6 w-6"
/>
<h2 class="text-base font-semibold ml-2">
{{ contact.name || AppString.NO_CONTACT_NAME }}
</h2>
<router-link
:to="{
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" />
</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 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
v-if="showGiveNumbers && contact.did != activeDid"
class="ml-auto flex gap-1.5"
@ -271,7 +204,7 @@
<button
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400"
@click="openOfferDialog(contact.did)"
@click="openOfferDialog(contact.did, contact.name)"
>
Offer
</button>
@ -293,6 +226,33 @@
</ul>
<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" />
<OfferDialog ref="customOfferDialog" />
@ -308,33 +268,6 @@
/>
</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>
</template>
@ -345,6 +278,7 @@ import { IndexableType } from "dexie";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
@ -379,6 +313,7 @@ export default class ContactsView extends Vue {
contactInput = "";
contactEdit: Contact | null = null;
contactNewName = "";
contactsSelected: Array<string> = [];
// { "did:...": concatenated-descriptions } entry for each contact
givenByMeDescriptions: Record<string, string> = {};
// { "did:...": amount } entry for each contact
@ -404,7 +339,7 @@ export default class ContactsView extends Vue {
AppString = AppString;
libsUtil = libsUtil;
async created() {
public async created() {
await db.open();
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
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(
{
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) {
return;
}
@ -546,19 +491,20 @@ export default class ContactsView extends Vue {
}
}
async onClickNewContact(): Promise<void> {
if (!this.contactInput) {
private async onClickNewContact(): Promise<void> {
const contactInput = this.contactInput.trim();
if (!contactInput) {
this.danger("There was no contact info to add.", "No Contact");
return;
}
if (this.contactInput.startsWith(CONTACT_URL_PREFIX)) {
await this.addContactFromScan(this.contactInput);
if (contactInput.startsWith(CONTACT_URL_PREFIX)) {
await this.addContactFromScan(contactInput);
return;
}
if (this.contactInput.startsWith(CONTACT_CSV_HEADER)) {
const lines = this.contactInput.split(/\n/);
if (contactInput.startsWith(CONTACT_CSV_HEADER)) {
const lines = contactInput.split(/\n/);
const lineAdded = [];
for (const line of lines) {
if (!line.trim() || line.startsWith(CONTACT_CSV_HEADER)) {
@ -590,20 +536,21 @@ export default class ContactsView extends Vue {
return;
}
let did = this.contactInput;
if (contactInput.startsWith("did:")) {
let did = contactInput;
let name, publicKeyInput, nextPublicKeyHashInput;
const commaPos1 = this.contactInput.indexOf(",");
const commaPos1 = contactInput.indexOf(",");
if (commaPos1 > -1) {
did = this.contactInput.substring(0, commaPos1).trim();
name = this.contactInput.substring(commaPos1 + 1).trim();
const commaPos2 = this.contactInput.indexOf(",", commaPos1 + 1);
did = contactInput.substring(0, commaPos1).trim();
name = contactInput.substring(commaPos1 + 1).trim();
const commaPos2 = contactInput.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
name = this.contactInput.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = this.contactInput.substring(commaPos2 + 1).trim();
const commaPos3 = this.contactInput.indexOf(",", commaPos2 + 1);
name = contactInput.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = contactInput.substring(commaPos2 + 1).trim();
const commaPos3 = contactInput.indexOf(",", commaPos2 + 1);
if (commaPos3 > -1) {
publicKeyInput = this.contactInput.substring(commaPos2 + 1, commaPos3).trim(); // eslint-disable-line prettier/prettier
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
}
}
}
@ -611,7 +558,9 @@ export default class ContactsView extends Vue {
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");
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString(
"base64",
);
}
let nextPubKeyHashB64 = nextPublicKeyHashInput;
if (nextPubKeyHashB64 && /^[0-9A-Fa-f]{66}$/i.test(nextPubKeyHashB64)) {
@ -625,9 +574,33 @@ export default class ContactsView extends Vue {
nextPubKeyHashB64: nextPubKeyHashB64,
};
await this.addContact(newContact);
return;
}
if (contactInput.includes("[")) {
// assume there's a JSON array of contacts in the input
const jsonContactInput = contactInput.substring(
contactInput.indexOf("["),
contactInput.lastIndexOf("]") + 1,
);
try {
const contacts = JSON.parse(jsonContactInput);
(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;
}
this.danger("No contact info was found in that input.", "No Contact Info");
}
async addContactFromEndorserMobileLine(line: string): Promise<IndexableType> {
private async addContactFromEndorserMobileLine(
line: string,
): Promise<IndexableType> {
// Note that Endorser Mobile puts name first, then did, etc.
let name = line;
let did = "";
@ -668,7 +641,7 @@ export default class ContactsView extends Vue {
return db.contacts.add(newContact);
}
async addContactFromScan(url: string): Promise<void> {
private async addContactFromScan(url: string): Promise<void> {
const payload = getContactPayloadFromJwtUrl(url);
if (!payload) {
this.$notify(
@ -693,7 +666,7 @@ export default class ContactsView extends Vue {
}
}
async addContact(newContact: Contact) {
private async addContact(newContact: Contact) {
if (!newContact.did) {
this.danger("Cannot add a contact without a DID.", "Incomplete Contact");
return;
@ -782,56 +755,30 @@ export default class ContactsView extends Vue {
});
}
// prompt with confirmation if they want to delete a contact
confirmDeleteContact(contact: Contact) {
// note that this is also in DIDView.vue
private 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: "Delete",
text:
"Are you sure you want to remove " +
this.nameForDid(this.contacts, contact.did) +
" with DID " +
contact.did +
" from your contact list?",
title: "Set Visibility",
text: visibilityPrompt,
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);
const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
// 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 " +
this.nameForDid(this.contacts, contact.did) +
(contact.registered
? " -- especially since they are already marked as registered"
: "") +
"?",
onYes: async () => {
await this.register(contact);
},
},
-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);
try {
@ -896,28 +843,8 @@ export default class ContactsView extends 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,
);
}
async setVisibility(
// note that this is also in DIDView.vue
private async setVisibility(
contact: Contact,
visibility: 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 =
this.apiServer +
"/api/report/canDidExplicitlySeeMe?did=" +
@ -999,7 +927,7 @@ export default class ContactsView extends Vue {
type: "info",
title: "Visibility Refreshed",
text:
this.nameForContact(contact, true) +
libsUtil.nameForContact(contact, true) +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
@ -1033,22 +961,7 @@ export default class ContactsView extends Vue {
}
}
private nameForDid(contacts: Array<Contact>, did: string): 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) {
private confirmShowGiftedDialog(giverDid: string, recipientDid: string) {
// if they have unconfirmed amounts, ask to confirm those
if (
recipientDid === this.activeDid &&
@ -1093,13 +1006,13 @@ export default class ContactsView extends Vue {
if (giverDid) {
giver = {
did: giverDid,
name: this.nameForDid(this.contacts, giverDid),
name: libsUtil.nameForDid(this.activeDid, this.contacts, giverDid),
};
}
if (recipientDid) {
receiver = {
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) {
(this.$refs.customOfferDialog as OfferDialog).open(recipientDid);
}
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));
openOfferDialog(recipientDid: string, recipientName?: string) {
(this.$refs.customOfferDialog as OfferDialog).open(
recipientDid,
recipientName,
);
}
public async toggleShowContactAmounts() {
private async toggleShowContactAmounts() {
const newShowValue = !this.showGiveNumbers;
try {
await db.open();
@ -1183,7 +1087,7 @@ export default class ContactsView extends Vue {
this.loadGives();
}
}
public toggleShowGiveTotals() {
private toggleShowGiveTotals() {
if (this.showGiveTotals) {
this.showGiveTotals = false;
this.showGiveConfirmed = true;
@ -1196,7 +1100,7 @@ export default class ContactsView extends Vue {
}
}
public showGiveAmountsClassNames() {
private showGiveAmountsClassNames() {
return {
"from-slate-400": this.showGiveTotals,
"to-slate-700": this.showGiveTotals,
@ -1206,76 +1110,31 @@ export default class ContactsView extends Vue {
"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;
}
/*
Tooltip, generated on "title" attributes on "fa" icons
Kudos to https://www.w3schools.com/css/css_tooltip.asp
*/
/* Tooltip container */
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
}
/* Tooltip text */
.tooltip .tooltiptext {
visibility: hidden;
width: 200px;
background-color: black;
color: #fff;
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 1;
}
/* 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;
private copySelectedContacts() {
if (this.contactsSelected.length === 0) {
this.danger("You must select contacts to copy.");
return;
}
const selectedContacts = this.contacts.filter((c) =>
this.contactsSelected.includes(c.did),
);
const message =
"To add contacts, paste this into the box on the 'People' screen.\n\n" +
JSON.stringify(selectedContacts, null, 2);
useClipboard()
.copy(message)
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Those contacts were copied to the clipboard. Have them paste it in the box on their 'People' screen.",
},
5000,
);
});
}
}
</style>
</script>

430
src/views/DIDView.vue

@ -22,12 +22,21 @@
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div>
<h2 class="text-xl font-semibold">
{{
didInfoForContact(viewingDid, activeDid, contact, allMyDids)
.displayName
}}
{{ contact?.name || "(no name)" }}
<button
@click="
contactEdit = true;
contactNewName = contact.name || '';
"
title="Edit"
>
<fa icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
</h2>
<button @click="showDidDetails = !showDidDetails" class="ml-2 mr-2">
<button
@click="showDidDetails = !showDidDetails"
class="ml-2 mr-2 mt-4"
>
Details
<fa v-if="showDidDetails" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" />
@ -49,8 +58,68 @@
/>
</span>
</div>
<div class="mt-4">
<div class="flex justify-center">Auto-Generated Icon:</div>
<div class="flex justify-between mt-4">
<div class="flex items-center">
<div v-if="activeDid" class="flex justify-between">
<div>
<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" />
</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"
@ -60,6 +129,7 @@
/>
</div>
</div>
</div>
<div
v-if="showLargeIdenticonId || showLargeIdenticonUrl"
class="fixed z-[100] top-0 inset-x-0 w-full"
@ -80,6 +150,32 @@
</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 -->
<div
@ -126,15 +222,16 @@
v-if="!isLoading && claims.length === 0"
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>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as yaml from "js-yaml";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
@ -152,6 +249,8 @@ import {
GenericVerifiableCredential,
GiveVerifiableCredential,
OfferVerifiableCredential,
register,
setVisibilityUtil,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import EntityIcon from "@/components/EntityIcon.vue";
@ -174,7 +273,9 @@ export default class DIDView extends Vue {
allMyDids: Array<string> = [];
apiServer = "";
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
contact?: Contact;
contact: Contact;
contactEdit = false;
contactNewName?: string;
contactYaml = "";
hitEnd = false;
isLoading = false;
@ -195,23 +296,29 @@ export default class DIDView extends Vue {
this.apiServer = (settings?.apiServer as string) || "";
const pathParam = window.location.pathname.substring("/did/".length);
let theContact: Contact | undefined;
if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam);
this.contact = await db.contacts.get(this.viewingDid);
this.contactYaml = yaml.dump(this.contact);
await this.loadClaimsAbout();
theContact = await db.contacts.get(this.viewingDid);
}
if (theContact) {
this.contact = theContact;
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "No claim ID was provided.",
text: "No valid claim ID was provided.",
},
-1,
);
return;
}
this.contactYaml = yaml.dump(this.contact);
await this.loadClaimsAbout();
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
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() {
if (!this.viewingDid) {
console.error("This should never be called without a DID.");
@ -323,5 +552,178 @@ export default class DIDView extends Vue {
claimDescription(claim: GenericVerifiableCredential) {
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>
<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
} catch (e: any) {
console.error("Error with feed load:", e);
// this sometimes gives different information
console.error("Error with feed load (error added): " + e);
this.$notify(
{
group: "alert",

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

@ -269,6 +269,7 @@ export default class GiftedDetails extends Vue {
this.hideBackButton =
(this.$route as Router).query["hideBackButton"] === "true";
this.message = ((this.$route as Router).query["message"] as string) || "";
// find any offer ID
const fulfills = this.prevCredToEdit?.claim?.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.givenToRecipient = !this.givenToProject && !!this.recipientDid;
@ -549,7 +551,7 @@ export default class GiftedDetails extends Vue {
group: "alert",
type: "warning",
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,
);
@ -574,7 +576,7 @@ export default class GiftedDetails extends Vue {
group: "alert",
type: "warning",
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,
);
@ -694,7 +696,6 @@ export default class GiftedDetails extends Vue {
constructGiveParam() {
const recipientDid = this.givenToRecipient ? this.recipientDid : undefined;
const projectId = this.givenToProject ? this.projectId : undefined;
// const giveClaim = constructGive(
const giveClaim = hydrateGive(
this.prevCredToEdit?.claim as GiveVerifiableCredential,
this.giverDid,

4
src/views/NewEditProjectView.vue

@ -97,8 +97,8 @@
/>
<input
:disabled="!startDateInput"
v-model="startTimeInput"
placeholder="Start Time"
v-model="startTimeInput"
type="time"
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;
}
try {
const headers = getHeaders(this.activeDid) as AxiosRequestHeaders;
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/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 class="text-center">
<button
data-testId="offerButton"
@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"
>
@ -169,7 +170,11 @@
</button>
</div>
</div>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
<OfferDialog
ref="customOfferDialog"
:projectId="this.projectId"
:projectName="this.name"
/>
<div v-if="activeDid && isRegistered">
<div class="text-center">

70
src/views/ProjectsView.vue

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

13
src/views/SharedPhotoView.vue

@ -48,6 +48,17 @@
</div>
<div v-else class="text-center mb-4">
<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>
</section>
</template>
@ -122,7 +133,7 @@ export default class SharedPhotoView extends Vue {
name: "gifted-details",
// this might be wrong since "name" goes with params, but it works so test well when you change it
query: {
destinationPathAfter: "/home",
destinationPathAfter: "/",
hideBackButton: true,
imageUrl: url,
recipientDid: this.activeDid,

15
sw_scripts/safari-notifications.js

@ -566,14 +566,27 @@ async function getNotificationCount() {
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.
// @param photo - image Blob to store for later retrieval after redirect
async function savePhoto(photo) {
try {
const photoBase64 = await blobToBase64String(photo);
const db = await openIndexedDB("TimeSafari");
const transaction = db.transaction("temp", "readwrite");
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();
} catch (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);
// Standard texts
const standardTitle = "Idea ";
const standardDescription = "Description of Idea ";
const standardTitle = 'Idea ';
const standardDescription = 'Description of Idea ';
// Combine texts with the random string
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
await page.goto('./projects');
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
// Search for newly-created project in /discover
await page.goto('./discover');
await page.waitForTimeout(3000); // Wait for a bit
await page.getByPlaceholder('Search…').fill(finalRandomString);
await page.locator('#QuickSearch button').click();
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';
test('Record something given', async ({ page }) => {
// Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18);
// 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 string of a few characters
const randomString = Math.random().toString(36).substring(2, 6);
// Generate a random non-zero single-digit number
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
// Standard title prefix
const standardTitle = "Gift ";
const standardTitle = 'Gift ';
// Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString;
const finalTitle = standardTitle + randomString;
// Import user 00
await importUser(page, '00');
@ -27,7 +21,7 @@ test('Record something given', async ({ page }) => {
await page.goto('./');
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click();
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 expect(page.getByText('That gift was recorded.')).toBeVisible();

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

@ -1,5 +1,6 @@
import path from 'path';
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
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
const finalTitle = `Gift ${randomString} from image-share`;
// Create new ID using seed phrase "rigid shrug mobile…"
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();
await importUser(page, '00');
// Record something given
await page.goto('./test');
@ -38,3 +35,35 @@ test('Record item given from image-share', async ({ page }) => {
const item1 = page.locator('li').filter({ hasText: finalTitle });
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');
// });

11
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;
// Standard title prefix
const standardTitle = "Gift ";
const standardTitle = 'Gift ';
// Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString;
// Contact name
const contactName = 'Contact 00';
const contactName = 'Contact #000';
// Import user 01
await importUser(page, '01');
// Add new contact 00
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 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();
// 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
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 page.getByPlaceholder('Name', { exact: true }).fill(contactName);
await page.locator('.dialog > .flex > button').first().click();

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;
// 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.getByPlaceholder('Seed Phrase').fill(seedPhrase);
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
await page.getByRole('link', { name: 'Set Your Name' }).click();

2
vite.config.mjs

@ -16,7 +16,7 @@ export default defineConfig({
srcDir: '.',
filename: 'sw_scripts-combined.js',
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.
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