Browse Source

Merge branch 'master' into clean-db-disconnects

- Resolves merge conflicts from master branch integration
- Includes latest features and bug fixes from master
- Maintains clean-db-disconnects branch functionality

Files affected: Multiple components, views, and utilities
Timestamp: Wed Oct 22 07:26:21 AM UTC 2025
pull/204/head
Matthew Raymer 16 hours ago
parent
commit
455dfadb92
  1. 2
      index.html
  2. 91
      package-lock.json
  3. 4
      package.json
  4. 38
      src/assets/styles/tailwind.css
  5. 22
      src/components/ActivityListItem.vue
  6. 2
      src/components/ContactListItem.vue
  7. 9
      src/components/DataExportSection.vue
  8. 376
      src/components/MembersList.vue
  9. 333
      src/components/SetBulkVisibilityDialog.vue
  10. 15
      src/components/TopMessage.vue
  11. 2
      src/constants/accountView.ts
  12. 9
      src/constants/app.ts
  13. 7
      src/db-sql/migration.ts
  14. 47
      src/db/databaseUtil.ts
  15. 11
      src/db/tables/settings.ts
  16. 4
      src/interfaces/claims.ts
  17. 13
      src/interfaces/records.ts
  18. 140
      src/libs/endorserServer.ts
  19. 8
      src/libs/fontawesome.ts
  20. 10
      src/router/index.ts
  21. 7
      src/services/api.ts
  22. 7
      src/test/PlatformServiceMixinTest.vue
  23. 26
      src/utils/PlatformServiceMixin.ts
  24. 35
      src/utils/logger.ts
  25. 80
      src/views/AccountViewView.vue
  26. 30
      src/views/ClaimAddRawView.vue
  27. 50
      src/views/ClaimView.vue
  28. 29
      src/views/ConfirmContactView.vue
  29. 35
      src/views/ConfirmGiftView.vue
  30. 25
      src/views/ContactAmountsView.vue
  31. 33
      src/views/ContactEditView.vue
  32. 28
      src/views/ContactGiftingView.vue
  33. 32
      src/views/ContactImportView.vue
  34. 39
      src/views/ContactQRScanFullView.vue
  35. 37
      src/views/ContactQRScanShowView.vue
  36. 23
      src/views/ContactsView.vue
  37. 35
      src/views/DIDView.vue
  38. 15
      src/views/DeepLinkErrorView.vue
  39. 9
      src/views/DeepLinkRedirectView.vue
  40. 155
      src/views/DiscoverView.vue
  41. 37
      src/views/GiftedDetailsView.vue
  42. 33
      src/views/HelpNotificationTypesView.vue
  43. 33
      src/views/HelpNotificationsView.vue
  44. 8
      src/views/HelpOnboardingView.vue
  45. 32
      src/views/HelpView.vue
  46. 129
      src/views/HomeView.vue
  47. 29
      src/views/IdentitySwitcherView.vue
  48. 29
      src/views/ImportAccountView.vue
  49. 31
      src/views/ImportDerivedAccountView.vue
  50. 35
      src/views/InviteOneView.vue
  51. 31
      src/views/LogView.vue
  52. 629
      src/views/NewActivityView.vue
  53. 29
      src/views/NewEditAccountView.vue
  54. 30
      src/views/NewEditProjectView.vue
  55. 33
      src/views/NewIdentifierView.vue
  56. 88
      src/views/NotFoundView.vue
  57. 34
      src/views/OfferDetailsView.vue
  58. 24
      src/views/OnboardMeetingListView.vue
  59. 26
      src/views/OnboardMeetingMembersView.vue
  60. 64
      src/views/OnboardMeetingSetupView.vue
  61. 212
      src/views/ProjectViewView.vue
  62. 37
      src/views/ProjectsView.vue
  63. 33
      src/views/QuickActionBvcBeginView.vue
  64. 32
      src/views/QuickActionBvcEndView.vue
  65. 35
      src/views/QuickActionBvcView.vue
  66. 28
      src/views/RecentOffersToUserProjectsView.vue
  67. 28
      src/views/RecentOffersToUserView.vue
  68. 35
      src/views/SearchAreaView.vue
  69. 38
      src/views/SeedBackupView.vue
  70. 35
      src/views/ShareMyContactInfoView.vue
  71. 26
      src/views/SharedPhotoView.vue
  72. 35
      src/views/StartView.vue
  73. 33
      src/views/StatisticsView.vue
  74. 31
      src/views/TestView.vue
  75. 33
      src/views/UserProfileView.vue
  76. 5
      test-playwright/20-create-project.spec.ts
  77. 4
      test-playwright/60-new-activity.spec.ts
  78. 5
      test-playwright/testUtils.ts

2
index.html

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, interactive-widget=overlays-content" />
<!-- CORS headers removed to allow images from any domain --> <!-- CORS headers removed to allow images from any domain -->

91
package-lock.json

@ -27,6 +27,7 @@
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0", "@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6", "@fortawesome/vue-fontawesome": "^3.0.6",
"@jlongster/sql.js": "^1.6.7", "@jlongster/sql.js": "^1.6.7",
@ -90,6 +91,7 @@
"vue": "3.5.13", "vue": "3.5.13",
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-facing-decorator": "3.0.4", "vue-facing-decorator": "3.0.4",
"vue-markdown-render": "^2.2.1",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3", "vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
@ -106,6 +108,7 @@
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.14.11", "@types/node": "^20.14.11",
"@types/node-fetch": "^2.6.12", "@types/node-fetch": "^2.6.12",
"@types/ramda": "^0.29.11", "@types/ramda": "^0.29.11",
@ -6786,6 +6789,17 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
"integrity": "sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": { "node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.7.2", "version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
@ -10147,6 +10161,12 @@
"@types/geojson": "*" "@types/geojson": "*"
} }
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true
},
"node_modules/@types/luxon": { "node_modules/@types/luxon": {
"version": "3.7.1", "version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
@ -10154,6 +10174,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dev": true,
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true
},
"node_modules/@types/minimist": { "node_modules/@types/minimist": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
@ -32883,6 +32919,61 @@
"vue": "^3.0.0" "vue": "^3.0.0"
} }
}, },
"node_modules/vue-markdown-render": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/vue-markdown-render/-/vue-markdown-render-2.2.1.tgz",
"integrity": "sha512-XkYnC0PMdbs6Vy6j/gZXSvCuOS0787Se5COwXlepRqiqPiunyCIeTPQAO2XnB4Yl04EOHXwLx5y6IuszMWSgyQ==",
"dependencies": {
"markdown-it": "^13.0.2"
},
"peerDependencies": {
"vue": "^3.3.4"
}
},
"node_modules/vue-markdown-render/node_modules/entities": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/vue-markdown-render/node_modules/linkify-it": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz",
"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==",
"dependencies": {
"uc.micro": "^1.0.1"
}
},
"node_modules/vue-markdown-render/node_modules/markdown-it": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.2.tgz",
"integrity": "sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==",
"dependencies": {
"argparse": "^2.0.1",
"entities": "~3.0.1",
"linkify-it": "^4.0.1",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"bin": {
"markdown-it": "bin/markdown-it.js"
}
},
"node_modules/vue-markdown-render/node_modules/mdurl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="
},
"node_modules/vue-markdown-render/node_modules/uc.micro": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
},
"node_modules/vue-picture-cropper": { "node_modules/vue-picture-cropper": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/vue-picture-cropper/-/vue-picture-cropper-0.7.0.tgz", "resolved": "https://registry.npmjs.org/vue-picture-cropper/-/vue-picture-cropper-0.7.0.tgz",

4
package.json

@ -136,7 +136,6 @@
"*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true", "*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true",
"*.{md,markdown,mdc}": "markdownlint-cli2 --fix" "*.{md,markdown,mdc}": "markdownlint-cli2 --fix"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/electron": "^5.0.1", "@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2", "@capacitor-community/sqlite": "6.0.2",
@ -157,6 +156,7 @@
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0", "@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6", "@fortawesome/vue-fontawesome": "^3.0.6",
"@jlongster/sql.js": "^1.6.7", "@jlongster/sql.js": "^1.6.7",
@ -220,6 +220,7 @@
"vue": "3.5.13", "vue": "3.5.13",
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-facing-decorator": "3.0.4", "vue-facing-decorator": "3.0.4",
"vue-markdown-render": "^2.2.1",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3", "vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
@ -236,6 +237,7 @@
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.14.11", "@types/node": "^20.14.11",
"@types/node-fetch": "^2.6.12", "@types/node-fetch": "^2.6.12",
"@types/ramda": "^0.29.11", "@types/ramda": "^0.29.11",

38
src/assets/styles/tailwind.css

@ -7,6 +7,24 @@
html { html {
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important; font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
} }
/* Fix iOS viewport height changes when keyboard appears/disappears */
html, body {
height: 100%;
height: 100vh;
height: 100dvh; /* Dynamic viewport height for better mobile support */
overflow: hidden; /* Disable all scrolling on html and body */
position: fixed; /* Force fixed positioning to prevent viewport changes */
width: 100%;
top: 0;
left: 0;
}
#app {
height: 100vh;
height: 100dvh;
overflow-y: auto;
}
} }
@layer components { @layer components {
@ -22,4 +40,24 @@
.dialog { .dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg; @apply bg-white p-4 rounded-lg w-full max-w-lg;
} }
/* Markdown content styling to restore list elements */
.markdown-content ul {
@apply list-disc list-inside ml-4;
}
.markdown-content ol {
@apply list-decimal list-inside ml-4;
}
.markdown-content li {
@apply mb-1;
}
.markdown-content ul ul,
.markdown-content ol ol,
.markdown-content ul ol,
.markdown-content ol ul {
@apply ml-4 mt-1;
}
} }

22
src/components/ActivityListItem.vue

@ -78,9 +78,15 @@
</div> </div>
<!-- Description --> <!-- Description -->
<p class="font-medium"> <p class="font-medium overflow-hidden">
<a class="cursor-pointer" @click="emitLoadClaim(record.jwtId)"> <a
{{ description }} class="block cursor-pointer overflow-hidden text-ellipsis"
@click="emitLoadClaim(record.jwtId)"
>
<vue-markdown
:source="truncatedDescription"
class="markdown-content"
/>
</a> </a>
</p> </p>
@ -258,11 +264,13 @@ import {
NOTIFY_UNKNOWN_PERSON, NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { TIMEOUTS } from "@/utils/notify"; import { TIMEOUTS } from "@/utils/notify";
import VueMarkdown from "vue-markdown-render";
@Component({ @Component({
components: { components: {
EntityIcon, EntityIcon,
ProjectIcon, ProjectIcon,
VueMarkdown,
}, },
}) })
export default class ActivityListItem extends Vue { export default class ActivityListItem extends Vue {
@ -303,6 +311,14 @@ export default class ActivityListItem extends Vue {
return `${claim?.description || ""}`; return `${claim?.description || ""}`;
} }
get truncatedDescription(): string {
const desc = this.description;
if (desc.length <= 300) {
return desc;
}
return desc.substring(0, 300) + "...";
}
private displayAmount(code: string, amt: number) { private displayAmount(code: string, amt: number) {
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`; return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`;
} }

2
src/components/ContactListItem.vue

@ -46,7 +46,7 @@
<span class="text-xs truncate">{{ contact.did }}</span> <span class="text-xs truncate">{{ contact.did }}</span>
</div> </div>
<div class="text-sm"> <div class="text-sm truncate">
{{ contact.notes }} {{ contact.notes }}
</div> </div>
</div> </div>

9
src/components/DataExportSection.vue

@ -18,7 +18,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
> >
<!-- Notification dot - show while the user has not yet backed up their seed phrase --> <!-- Notification dot - show while the user has not yet backed up their seed phrase -->
<font-awesome <font-awesome
v-if="!hasBackedUpSeed" v-if="showRedNotificationDot"
icon="circle" icon="circle"
class="absolute -right-[8px] -top-[8px] text-rose-500 text-[14px] border border-white rounded-full" class="absolute -right-[8px] -top-[8px] text-rose-500 text-[14px] border border-white rounded-full"
></font-awesome> ></font-awesome>
@ -108,7 +108,7 @@ export default class DataExportSection extends Vue {
* Flag indicating if the user has backed up their seed phrase * Flag indicating if the user has backed up their seed phrase
* Used to control the visibility of the notification dot * Used to control the visibility of the notification dot
*/ */
hasBackedUpSeed = false; showRedNotificationDot = false;
/** /**
* Notification helper for consistent notification patterns * Notification helper for consistent notification patterns
@ -240,11 +240,12 @@ export default class DataExportSection extends Vue {
private async loadSeedBackupStatus(): Promise<void> { private async loadSeedBackupStatus(): Promise<void> {
try { try {
const settings = await this.$accountSettings(); const settings = await this.$accountSettings();
this.hasBackedUpSeed = !!settings.hasBackedUpSeed; this.showRedNotificationDot =
!!settings.isRegistered && !settings.hasBackedUpSeed;
} catch (err: unknown) { } catch (err: unknown) {
logger.error("Failed to load seed backup status:", err); logger.error("Failed to load seed backup status:", err);
// Default to false (show notification dot) if we can't load the setting // Default to false (show notification dot) if we can't load the setting
this.hasBackedUpSeed = false; this.showRedNotificationDot = false;
} }
} }
} }

376
src/components/MembersList.vue

@ -11,7 +11,7 @@
<!-- Members List --> <!-- Members List -->
<div v-else> <div v-else>
<div class="text-center text-red-600 py-4"> <div class="text-center text-red-600 my-4">
{{ decryptionErrorMessage() }} {{ decryptionErrorMessage() }}
</div> </div>
@ -23,97 +23,94 @@
to set it. to set it.
</div> </div>
<div> <ul class="list-disc text-sm ps-4 space-y-2 mb-4">
<span <li
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer" v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
class="inline-flex items-center flex-wrap"
> >
<span class="inline-flex items-center"> Click
&bull; Click <span
<span class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600" >
> <font-awesome icon="plus" class="text-sm" />
<font-awesome icon="plus" class="text-sm" />
</span>
/
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<font-awesome icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting.
</span> </span>
</span> /
</div> <span
<div> class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
<span >
v-if="membersToShow().length > 0" <font-awesome icon="minus" class="text-sm" />
class="inline-flex items-center" </span>
> to add/remove them to/from the meeting.
&bull; Click </li>
<li v-if="membersToShow().length > 0">
Click
<span <span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600" class="inline-block w-5 h-5 rounded-full bg-green-100 text-green-600 text-center"
> >
<font-awesome icon="circle-user" class="text-xl" /> <font-awesome icon="circle-user" class="text-sm" />
</span> </span>
to add them to your contacts. to add them to your contacts.
</span> </li>
</div> </ul>
<div class="flex justify-center"> <div class="flex justify-between">
<!-- <!--
always have at least one refresh button even without members in case the organizer always have at least one refresh button even without members in case the organizer
changes the password changes the password
--> -->
<button <button
class="btn-action-refresh" class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
title="Refresh members list" title="Refresh members list now"
@click="fetchMembers" @click="manualRefresh"
> >
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" /> <font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button> </button>
</div> </div>
<div <ul
v-for="member in membersToShow()" v-if="membersToShow().length > 0"
:key="member.member.memberId" class="border-t border-slate-300 my-2"
class="mt-2 p-4 bg-gray-50 rounded-lg"
> >
<div class="flex items-center justify-between"> <li
<div class="flex items-center"> v-for="member in membersToShow()"
<h3 class="text-lg font-medium"> :key="member.member.memberId"
{{ member.name || unnamedMember }} class="border-b border-slate-300 py-1.5"
</h3> >
<div <div class="flex items-center gap-2 justify-between">
v-if="!getContactFor(member.did) && member.did !== activeDid" <div class="flex items-center gap-1 overflow-hidden">
class="flex justify-end" <h3 class="font-semibold truncate">
> {{ member.name || unnamedMember }}
<button </h3>
class="btn-add-contact" <div
title="Add as contact" v-if="!getContactFor(member.did) && member.did !== activeDid"
@click="addAsContact(member)" class="flex items-center gap-1"
> >
<font-awesome icon="circle-user" class="text-xl" /> <button
</button> class="btn-add-contact"
title="Add as contact"
@click="addAsContact(member)"
>
<font-awesome icon="circle-user" />
</button>
<button
class="btn-info-contact"
title="Contact Info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
>
<font-awesome icon="circle-info" class="text-sm" />
</button>
</div>
</div> </div>
<button
v-if="member.did !== activeDid"
class="btn-info-contact"
title="Contact info"
@click="
informAboutAddingContact(
getContactFor(member.did) !== undefined,
)
"
>
<font-awesome icon="circle-info" class="text-base" />
</button>
</div>
<div class="flex">
<span <span
v-if=" v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid showOrganizerTools && isOrganizer && member.did !== activeDid
" "
class="flex items-center" class="flex items-center gap-1"
> >
<button <button
class="btn-admission" class="btn-admission"
@ -124,30 +121,37 @@
> >
<font-awesome <font-awesome
:icon="member.member.admitted ? 'minus' : 'plus'" :icon="member.member.admitted ? 'minus' : 'plus'"
class="text-sm"
/> />
</button> </button>
<button <button
class="btn-info-admission" class="btn-info-admission"
title="Admission info" title="Admission Info"
@click="informAboutAdmission()" @click="informAboutAdmission()"
> >
<font-awesome icon="circle-info" class="text-base" /> <font-awesome icon="circle-info" class="text-sm" />
</button> </button>
</span> </span>
</div> </div>
</div> <p class="text-xs text-gray-600 truncate">
<p class="text-sm text-gray-600 truncate"> {{ member.did }}
{{ member.did }} </p>
</p> </li>
</div> </ul>
<div v-if="membersToShow().length > 0" class="flex justify-center mt-4">
<div v-if="membersToShow().length > 0" class="flex justify-between">
<!--
always have at least one refresh button even without members in case the organizer
changes the password
-->
<button <button
class="btn-action-refresh" class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
title="Refresh members list" title="Refresh members list now"
@click="fetchMembers" @click="manualRefresh"
> >
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" /> <font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh
<span class="text-xs">({{ countdownTimer }}s)</span>
</button> </button>
</div> </div>
@ -156,6 +160,15 @@
</p> </p>
</div> </div>
</div> </div>
<!-- Set Visibility Dialog Component -->
<SetBulkVisibilityDialog
:visible="showSetVisibilityDialog"
:members-data="visibilityDialogMembers"
:active-did="activeDid"
:api-server="apiServer"
@close="closeSetVisibilityDialog"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -178,6 +191,7 @@ import {
NOTIFY_CONTINUE_WITHOUT_ADDING, NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities"; import { SOMEONE_UNNAMED } from "@/constants/entities";
import SetBulkVisibilityDialog from "./SetBulkVisibilityDialog.vue";
interface Member { interface Member {
admitted: boolean; admitted: boolean;
@ -193,6 +207,9 @@ interface DecryptedMember {
} }
@Component({ @Component({
components: {
SetBulkVisibilityDialog,
},
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
export default class MembersList extends Vue { export default class MembersList extends Vue {
@ -219,8 +236,25 @@ export default class MembersList extends Vue {
missingMyself = false; missingMyself = false;
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
// Set Visibility Dialog state
showSetVisibilityDialog = false;
visibilityDialogMembers: Array<{
did: string;
name: string;
isContact: boolean;
member: { memberId: string };
}> = [];
contacts: Array<Contact> = []; contacts: Array<Contact> = [];
// Auto-refresh functionality
countdownTimer = 10;
autoRefreshInterval: NodeJS.Timeout | null = null;
lastRefreshTime = 0;
// Track previous visibility members to detect changes
previousVisibilityMembers: string[] = [];
/** /**
* Get the unnamed member constant * Get the unnamed member constant
*/ */
@ -242,6 +276,21 @@ export default class MembersList extends Vue {
this.firstName = settings.firstName || ""; this.firstName = settings.firstName || "";
await this.fetchMembers(); await this.fetchMembers();
await this.loadContacts(); await this.loadContacts();
// Start auto-refresh
this.startAutoRefresh();
// Check if we should show the visibility dialog on initial load
this.checkAndShowVisibilityDialog();
}
async refreshData() {
// Force refresh both contacts and members
await this.loadContacts();
await this.fetchMembers();
// Check if we should show the visibility dialog after refresh
this.checkAndShowVisibilityDialog();
} }
async fetchMembers() { async fetchMembers() {
@ -344,7 +393,7 @@ export default class MembersList extends Vue {
informAboutAdmission() { informAboutAdmission() {
this.notify.info( this.notify.info(
"This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.", "This is to register people in Time Safari and to admit them to the meeting. A (+) symbol means they are not yet admitted and you can register and admit them. A (-) symbol means you can remove them, but they will stay registered.",
TIMEOUTS.VERY_LONG, TIMEOUTS.VERY_LONG,
); );
} }
@ -371,6 +420,80 @@ export default class MembersList extends Vue {
return this.contacts.find((contact) => contact.did === did); return this.contacts.find((contact) => contact.did === did);
} }
getMembersForVisibility() {
return this.decryptedMembers
.filter((member) => {
// Exclude the current user
if (member.did === this.activeDid) {
return false;
}
const contact = this.getContactFor(member.did);
// Include members who:
// 1. Haven't been added as contacts yet, OR
// 2. Are contacts but don't have visibility set (seesMe property)
return !contact || !contact.seesMe;
})
.map((member) => ({
did: member.did,
name: member.name,
isContact: !!this.getContactFor(member.did),
member: {
memberId: member.member.memberId.toString(),
},
}));
}
/**
* Check if we should show the visibility dialog
* Returns true if there are members for visibility and either:
* - This is the first time (no previous members tracked), OR
* - New members have been added since last check (not removed)
*/
shouldShowVisibilityDialog(): boolean {
const currentMembers = this.getMembersForVisibility();
if (currentMembers.length === 0) {
return false;
}
// If no previous members tracked, show dialog
if (this.previousVisibilityMembers.length === 0) {
return true;
}
// Check if new members have been added (not just any change)
const currentMemberIds = currentMembers.map((m) => m.did);
const previousMemberIds = this.previousVisibilityMembers;
// Find new members (members in current but not in previous)
const newMembers = currentMemberIds.filter(
(id) => !previousMemberIds.includes(id),
);
// Only show dialog if there are new members added
return newMembers.length > 0;
}
/**
* Update the tracking of previous visibility members
*/
updatePreviousVisibilityMembers() {
const currentMembers = this.getMembersForVisibility();
this.previousVisibilityMembers = currentMembers.map((m) => m.did);
}
/**
* Show the visibility dialog if conditions are met
*/
checkAndShowVisibilityDialog() {
if (this.shouldShowVisibilityDialog()) {
this.showSetBulkVisibilityDialog();
}
this.updatePreviousVisibilityMembers();
}
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) { checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did); const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) { if (!decrMember.member.admitted && !contact) {
@ -508,6 +631,79 @@ export default class MembersList extends Vue {
this.notify.error(message, TIMEOUTS.LONG); this.notify.error(message, TIMEOUTS.LONG);
} }
} }
showSetBulkVisibilityDialog() {
// Filter members to show only those who need visibility set
const membersForVisibility = this.getMembersForVisibility();
// Pause auto-refresh when dialog opens
this.stopAutoRefresh();
// Open the dialog directly
this.visibilityDialogMembers = membersForVisibility;
this.showSetVisibilityDialog = true;
}
startAutoRefresh() {
this.lastRefreshTime = Date.now();
this.countdownTimer = 10;
this.autoRefreshInterval = setInterval(() => {
const now = Date.now();
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
if (timeSinceLastRefresh >= 10) {
// Time to refresh
this.refreshData();
this.lastRefreshTime = now;
this.countdownTimer = 10;
} else {
// Update countdown
this.countdownTimer = Math.max(
0,
Math.round(10 - timeSinceLastRefresh),
);
}
}, 1000); // Update every second
}
stopAutoRefresh() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
}
manualRefresh() {
// Clear existing auto-refresh interval
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
// Trigger immediate refresh and restart timer
this.refreshData();
this.startAutoRefresh();
// Always show dialog on manual refresh if there are members for visibility
if (this.getMembersForVisibility().length > 0) {
this.showSetBulkVisibilityDialog();
}
}
// Set Visibility Dialog methods
closeSetVisibilityDialog() {
this.showSetVisibilityDialog = false;
this.visibilityDialogMembers = [];
// Refresh data when dialog is closed
this.refreshData();
// Resume auto-refresh when dialog is closed
this.startAutoRefresh();
}
beforeDestroy() {
this.stopAutoRefresh();
}
} }
</script> </script>
@ -522,29 +718,23 @@ export default class MembersList extends Vue {
.btn-add-contact { .btn-add-contact {
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@apply ml-2 w-8 h-8 flex items-center justify-center rounded-full @apply w-6 h-6 flex items-center justify-center rounded-full
bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800 bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800
transition-colors; transition-colors;
} }
.btn-info-contact { .btn-info-contact,
.btn-info-admission {
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@apply ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full @apply w-6 h-6 flex items-center justify-center rounded-full
bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800 bg-slate-100 text-slate-400 hover:text-slate-600
transition-colors; transition-colors;
} }
.btn-admission { .btn-admission {
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@apply mr-2 w-6 h-6 flex items-center justify-center rounded-full @apply w-6 h-6 flex items-center justify-center rounded-full
bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800
transition-colors; transition-colors;
} }
.btn-info-admission {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full
bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800
transition-colors;
}
</style> </style>

333
src/components/SetBulkVisibilityDialog.vue

@ -0,0 +1,333 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<div class="text-slate-900 text-center">
<h3 class="text-lg font-semibold leading-[1.25] mb-2">
Set Visibility to Meeting Members
</h3>
<p class="text-sm mb-4">
Would you like to <b>make your activities visible</b> to the following
members? (This will also add them as contacts if they aren't already.)
</p>
<!-- Custom table area - you can customize this -->
<div v-if="shouldInitializeSelection" class="mb-4">
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<thead v-if="membersData && membersData.length > 0">
<tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
/>
Select All
</label>
</th>
</tr>
</thead>
<tbody>
<!-- Dynamic data from MembersList -->
<tr v-if="!membersData || membersData.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
No members need visibility settings
</td>
</tr>
<tr
v-for="member in membersData || []"
:key="member.member.memberId"
>
<td class="border border-slate-300 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)"
/>
{{ member.name || SOMEONE_UNNAMED }}
</label>
<!-- Friend indicator - only show if they are already a contact -->
<font-awesome
v-if="member.isContact"
icon="user-circle"
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
@click="showContactInfo"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="space-y-2">
<button
v-if="membersData && membersData.length > 0"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="setVisibilityForSelectedMembers"
>
Set Visibility
</button>
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="cancel"
>
{{
membersData && membersData.length > 0 ? "Maybe Later" : "Cancel"
}}
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { setVisibilityUtil } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
interface MemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}
@Component({
mixins: [PlatformServiceMixin],
})
export default class SetBulkVisibilityDialog extends Vue {
@Prop({ default: false }) visible!: boolean;
@Prop({ default: () => [] }) membersData!: MemberData[];
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
// Component state
selectedMembers: string[] = [];
selectionInitialized = false;
// Constants
// In Vue templates, imported constants need to be explicitly made available to the template
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED;
get hasSelectedMembers() {
return this.selectedMembers.length > 0;
}
get isAllSelected() {
if (!this.membersData || this.membersData.length === 0) return false;
return this.membersData.every((member) =>
this.selectedMembers.includes(member.did),
);
}
get isIndeterminate() {
if (!this.membersData || this.membersData.length === 0) return false;
const selectedCount = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
).length;
return selectedCount > 0 && selectedCount < this.membersData.length;
}
get shouldInitializeSelection() {
// This method will initialize selection when the dialog opens
if (!this.selectionInitialized) {
this.initializeSelection();
this.selectionInitialized = true;
}
return true;
}
created() {
this.notify = createNotifyHelpers(this.$notify);
}
initializeSelection() {
// Reset selection when dialog opens
this.selectedMembers = [];
// Select all by default
this.selectedMembers = this.membersData.map((member) => member.did);
}
resetSelection() {
this.selectedMembers = [];
this.selectionInitialized = false;
}
toggleSelectAll() {
if (!this.membersData || this.membersData.length === 0) return;
if (this.isAllSelected) {
// Deselect all
this.selectedMembers = [];
} else {
// Select all
this.selectedMembers = this.membersData.map((member) => member.did);
}
}
toggleMemberSelection(memberDid: string) {
const index = this.selectedMembers.indexOf(memberDid);
if (index > -1) {
this.selectedMembers.splice(index, 1);
} else {
this.selectedMembers.push(memberDid);
}
}
isMemberSelected(memberDid: string) {
return this.selectedMembers.includes(memberDid);
}
async setVisibilityForSelectedMembers() {
try {
const selectedMembers = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
let successCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member);
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
successCount++;
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set Successfully",
text: `Visibility set for ${successCount} member${successCount === 1 ? "" : "s"}.`,
},
5000,
);
// Emit success event
this.$emit("success", successCount);
this.close();
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error setting visibility:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to set visibility for some members. Please try again.",
},
5000,
);
}
}
async addAsContact(member: { did: string; name: string }) {
try {
const newContact = {
did: member.did,
name: member.name,
};
await this.$insertContact(newContact);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error adding contact:", err);
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
// Contact already exists, continue
} else {
throw err; // Re-throw if it's not a duplicate error
}
}
}
async updateContactVisibility(did: string, seesMe: boolean) {
try {
// Get the contact object
const contact = await this.$getContact(did);
if (!contact) {
throw new Error(`Contact not found for DID: ${did}`);
}
// Use the proper API to set visibility on the server
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
contact,
seesMe,
);
if (!result.success) {
throw new Error(result.error || "Failed to set visibility");
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error updating contact visibility:", err);
throw err;
}
}
showContactInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Info",
text: "This user is already your contact, but your activities are not visible to them yet.",
},
5000,
);
}
close() {
this.resetSelection();
this.$emit("close");
}
cancel() {
this.close();
}
}
</script>

15
src/components/TopMessage.vue

@ -1,16 +1,9 @@
<template> <template>
<div <div
class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top),var(--safe-area-inset-top,0px))]" v-if="message"
class="-mt-6 bg-rose-100 border border-t-0 border-dashed border-rose-600 text-rose-900 text-sm text-center font-semibold rounded-b-md px-3 py-2 mb-3"
> >
<span class="align-center text-red-500 mr-2">{{ message }}</span> {{ message }}
<span class="ml-2">
<router-link
:to="{ name: 'help' }"
class="text-xs uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md ml-1"
>
Help
</router-link>
</span>
</div> </div>
</template> </template>
@ -27,7 +20,7 @@ import { logger } from "../utils/logger";
}) })
export default class TopMessage extends Vue { export default class TopMessage extends Vue {
// Enhanced PlatformServiceMixin v4.0 provides: // Enhanced PlatformServiceMixin v4.0 provides:
// - Cached database operations: this.$contacts(), this.$settings(), this.$accountSettings() // - Cached database operations: this.$contacts(), this.$accountSettings()
// - Settings shortcuts: this.$saveSettings() // - Settings shortcuts: this.$saveSettings()
// - Cache management: this.$refreshSettings(), this.$clearAllCaches() // - Cache management: this.$refreshSettings(), this.$clearAllCaches()
// - Ultra-concise database methods: this.$db(), this.$exec(), this.$query() // - Ultra-concise database methods: this.$db(), this.$exec(), this.$query()

2
src/constants/accountView.ts

@ -86,7 +86,7 @@ export const ACCOUNT_VIEW_CONSTANTS = {
CANNOT_UPLOAD_IMAGES: "You cannot upload images.", CANNOT_UPLOAD_IMAGES: "You cannot upload images.",
BAD_SERVER_RESPONSE: "Bad server response.", BAD_SERVER_RESPONSE: "Bad server response.",
ERROR_RETRIEVING_LIMITS: ERROR_RETRIEVING_LIMITS:
"No limits were found, so no actions are allowed. You will need to get registered.", "No limits were found, so no actions are allowed. You need to get registered.",
}, },
// Project assignment errors // Project assignment errors

9
src/constants/app.ts

@ -59,7 +59,7 @@ export const PASSKEYS_ENABLED =
export interface NotificationIface { export interface NotificationIface {
group: string; // "alert" | "modal" group: string; // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger" type: string; // "toast" | "info" | "success" | "warning" | "danger"
title: string; title?: string;
text?: string; text?: string;
callback?: (success: boolean) => Promise<void>; // if this triggered an action callback?: (success: boolean) => Promise<void>; // if this triggered an action
noText?: string; noText?: string;
@ -68,4 +68,11 @@ export interface NotificationIface {
onYes?: () => Promise<void>; onYes?: () => Promise<void>;
promptToStopAsking?: boolean; promptToStopAsking?: boolean;
yesText?: string; yesText?: string;
membersData?: Array<{
member: { admitted: boolean; content: string; memberId: number };
name: string;
did: string;
isContact: boolean;
contact?: { did: string; name?: string; seesMe?: boolean };
}>; // For passing member data to visibility dialog
} }

7
src/db-sql/migration.ts

@ -192,6 +192,13 @@ const MIGRATIONS = [
name: "004_active_identity_management", name: "004_active_identity_management",
sql: MIG_004_SQL, sql: MIG_004_SQL,
}, },
{
name: "005_add_starredPlanHandleIds_to_settings",
sql: `
ALTER TABLE settings ADD COLUMN starredPlanHandleIds TEXT DEFAULT '[]'; -- JSON string
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
`,
},
]; ];
/** /**

47
src/db/databaseUtil.ts

@ -9,34 +9,6 @@ import { logger } from "@/utils/logger";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { QueryExecResult } from "@/interfaces/database"; import { QueryExecResult } from "@/interfaces/database";
export async function updateDefaultSettings(
settingsChanges: Settings,
): Promise<boolean> {
delete settingsChanges.accountDid; // just in case
// ensure there is no "id" that would override the key
delete settingsChanges.id;
try {
const platformService = PlatformServiceFactory.getInstance();
const { sql, params } = generateUpdateStatement(
settingsChanges,
"settings",
"id = ?",
[MASTER_SETTINGS_KEY],
);
const result = await platformService.dbExec(sql, params);
return result.changes === 1;
} catch (error) {
logger.error("Error updating default settings:", error);
if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message
} else {
throw new Error(
`Failed to update settings. We recommend you try again or restart the app.`,
);
}
}
}
export async function insertDidSpecificSettings( export async function insertDidSpecificSettings(
did: string, did: string,
settings: Partial<Settings> = {}, settings: Partial<Settings> = {},
@ -91,6 +63,7 @@ export async function updateDidSpecificSettings(
? mapColumnsToValues(postUpdateResult.columns, postUpdateResult.values)[0] ? mapColumnsToValues(postUpdateResult.columns, postUpdateResult.values)[0]
: null; : null;
// Note that we want to eliminate this check (and fix the above if it doesn't work).
// Check if any of the target fields were actually changed // Check if any of the target fields were actually changed
let actuallyUpdated = false; let actuallyUpdated = false;
if (currentRecord && updatedRecord) { if (currentRecord && updatedRecord) {
@ -157,10 +130,11 @@ export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
result.columns, result.columns,
result.values, result.values,
)[0] as Settings; )[0] as Settings;
if (settings.searchBoxes) { settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
// @ts-expect-error - the searchBoxes field is a string in the DB settings.starredPlanHandleIds = parseJsonField(
settings.searchBoxes = JSON.parse(settings.searchBoxes); settings.starredPlanHandleIds,
} [],
);
return settings; return settings;
} }
} }
@ -226,10 +200,11 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
); );
} }
// Handle searchBoxes parsing settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
if (settings.searchBoxes) { settings.starredPlanHandleIds = parseJsonField(
settings.searchBoxes = parseJsonField(settings.searchBoxes, []); settings.starredPlanHandleIds,
} [],
);
return settings; return settings;
} catch (error) { } catch (error) {

11
src/db/tables/settings.ts

@ -43,6 +43,7 @@ export type Settings = {
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred plan changes that they've acknowledged seeing
// The claim list has a most recent one used in notifications that's separate from the last viewed // The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string; lastNotifiedClaimId?: string;
@ -67,15 +68,18 @@ export type Settings = {
showContactGivesInline?: boolean; // Display contact inline or not showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
starredPlanHandleIds?: string[]; // Array of starred plan handle IDs
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server warnIfTestServer?: boolean; // Warn if using a testing server
webPushServer?: string; // Web Push server URL webPushServer?: string; // Web Push server URL
}; };
// type of settings where the searchBoxes are JSON strings instead of objects // type of settings where the values are JSON strings instead of objects
export type SettingsWithJsonStrings = Settings & { export type SettingsWithJsonStrings = Settings & {
searchBoxes: string; searchBoxes: string;
starredPlanHandleIds: string;
}; };
export function checkIsAnyFeedFilterOn(settings: Settings): boolean { export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
@ -92,6 +96,11 @@ export const SettingsSchema = {
/** /**
* Constants. * Constants.
*/ */
/**
* This is deprecated.
* It only remains for those with a PWA who have not migrated, but we'll soon remove it.
*/
export const MASTER_SETTINGS_KEY = "1"; export const MASTER_SETTINGS_KEY = "1";
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15; export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

4
src/interfaces/claims.ts

@ -72,11 +72,15 @@ export interface PlanActionClaim extends ClaimObject {
name: string; name: string;
agent?: { identifier: string }; agent?: { identifier: string };
description?: string; description?: string;
endTime?: string;
identifier?: string; identifier?: string;
image?: string;
lastClaimId?: string; lastClaimId?: string;
location?: { location?: {
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number }; geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
}; };
startTime?: string;
url?: string;
} }
// AKA Registration & RegisterAction // AKA Registration & RegisterAction

13
src/interfaces/records.ts

@ -1,4 +1,5 @@
import { GiveActionClaim, OfferClaim } from "./claims"; import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
import { GenericCredWrapper } from "./common";
// a summary record; the VC is found the fullClaim field // a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord { export interface GiveSummaryRecord {
@ -61,6 +62,11 @@ export interface PlanSummaryRecord {
jwtId?: string; jwtId?: string;
} }
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
}
/** /**
* Represents data about a project * Represents data about a project
* *
@ -87,7 +93,10 @@ export interface PlanData {
name: string; name: string;
/** /**
* The identifier of the project record -- different from jwtId * The identifier of the project record -- different from jwtId
* (Maybe we should use the jwtId to iterate through the records instead.) *
* This has been used to iterate through plan records, because jwtId ordering doesn't match
* chronological create ordering, though it does match most recent edit order (in reverse order).
* (It may be worthwhile to order by jwtId instead. It is an indexed field.)
**/ **/
rowId?: string; rowId?: string;
} }

140
src/libs/endorserServer.ts

@ -56,8 +56,13 @@ import {
KeyMetaWithPrivate, KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate, KeyMetaMaybeWithPrivate,
} from "../interfaces/common"; } from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records"; import {
import { logger, safeStringify } from "../utils/logger"; OfferSummaryRecord,
OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { APP_SERVER } from "@/constants/app"; import { APP_SERVER } from "@/constants/app";
import { SOMEONE_UNNAMED } from "@/constants/entities"; import { SOMEONE_UNNAMED } from "@/constants/entities";
@ -362,6 +367,22 @@ export function didInfo(
return didInfoForContact(did, activeDid, contact, allMyDids).displayName; return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
} }
/**
* In some contexts (eg. agent), a blank really is nobody.
*/
export function didInfoOrNobody(
did: string | undefined,
activeDid: string | undefined,
allMyDids: string[],
contacts: Contact[],
): string {
if (did == null) {
return "Nobody";
} else {
return didInfo(did, activeDid, allMyDids, contacts);
}
}
/** /**
* return text description without any references to "you" as user * return text description without any references to "you" as user
*/ */
@ -730,7 +751,7 @@ export async function getNewOffersToUser(
activeDid: string, activeDid: string,
afterOfferJwtId?: string, afterOfferJwtId?: string,
beforeOfferJwtId?: string, beforeOfferJwtId?: string,
) { ): Promise<{ data: Array<OfferSummaryRecord>; hitLimit: boolean }> {
let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`; let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`;
if (afterOfferJwtId) { if (afterOfferJwtId) {
url += "&afterId=" + afterOfferJwtId; url += "&afterId=" + afterOfferJwtId;
@ -752,7 +773,7 @@ export async function getNewOffersToUserProjects(
activeDid: string, activeDid: string,
afterOfferJwtId?: string, afterOfferJwtId?: string,
beforeOfferJwtId?: string, beforeOfferJwtId?: string,
) { ): Promise<{ data: Array<OfferToPlanSummaryRecord>; hitLimit: boolean }> {
let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`; let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`;
if (afterOfferJwtId) { if (afterOfferJwtId) {
url += "?afterId=" + afterOfferJwtId; url += "?afterId=" + afterOfferJwtId;
@ -766,6 +787,46 @@ export async function getNewOffersToUserProjects(
return response.data; return response.data;
} }
/**
* Get starred projects that have been updated since the last check
*
* @param axios - axios instance
* @param apiServer - endorser API server URL
* @param activeDid - user's DID for authentication
* @param starredPlanHandleIds - array of starred project handle IDs
* @param afterId - JWT ID to check for changes after (from lastAckedStarredPlanChangesJwtId)
* @returns { data: Array<PlanSummaryAndPreviousClaim>, hitLimit: boolean }
*/
export async function getStarredProjectsWithChanges(
axios: Axios,
apiServer: string,
activeDid: string,
starredPlanHandleIds: string[],
afterId?: string,
): Promise<{ data: Array<PlanSummaryAndPreviousClaim>; hitLimit: boolean }> {
if (!starredPlanHandleIds || starredPlanHandleIds.length === 0) {
return { data: [], hitLimit: false };
}
if (!afterId) {
// This doesn't make sense: there should always be some previous one they've seen.
// We'll just return blank.
return { data: [], hitLimit: false };
}
// Use POST method for larger lists of project IDs
const url = `${apiServer}/api/v2/report/plansLastUpdatedBetween`;
const headers = await getHeaders(activeDid);
const requestBody = {
planIds: starredPlanHandleIds,
afterId: afterId,
};
const response = await axios.post(url, requestBody, { headers });
return response.data;
}
/** /**
* Construct GiveAction VC for submission to server * Construct GiveAction VC for submission to server
* *
@ -1697,49 +1758,19 @@ export async function fetchEndorserRateLimits(
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
try { // not wrapped in a 'try' because the error returned is self-explanatory
const response = await axios.get(url, { headers } as AxiosRequestConfig); const response = await axios.get(url, { headers } as AxiosRequestConfig);
// Log successful registration check // Log successful registration check
logger.debug("[User Registration] User registration check successful:", { logger.debug("[User Registration] User registration check successful:", {
did: issuerDid, did: issuerDid,
server: apiServer, server: apiServer,
status: response.status, status: response.status,
isRegistered: true, isRegistered: true,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
return response;
} catch (error) {
// Enhanced error logging with user registration context
const axiosError = error as {
response?: {
data?: { error?: { code?: string; message?: string } };
status?: number;
};
};
const errorCode = axiosError.response?.data?.error?.code;
const errorMessage = axiosError.response?.data?.error?.message;
const httpStatus = axiosError.response?.status;
logger.warn("[User Registration] User not registered on server:", {
did: issuerDid,
server: apiServer,
errorCode: errorCode,
errorMessage: errorMessage,
httpStatus: httpStatus,
needsRegistration: true,
timestamp: new Date().toISOString(),
});
// Log the original error for debugging
logger.error(
`[fetchEndorserRateLimits] Error for DID ${issuerDid}:`,
errorStringForLog(error),
);
throw error; return response;
}
} }
/** /**
@ -1788,14 +1819,17 @@ export async function fetchImageRateLimits(
}; };
}; };
logger.error("[Image Server] Image rate limits check failed:", { logger.warn(
did: issuerDid, "[Image Server] Image rate limits check failed, which is expected for users not registered on test server (eg. when only registered on local server).",
server: server, {
errorCode: axiosError.response?.data?.error?.code, did: issuerDid,
errorMessage: axiosError.response?.data?.error?.message, server: server,
httpStatus: axiosError.response?.status, errorCode: axiosError.response?.data?.error?.code,
timestamp: new Date().toISOString(), errorMessage: axiosError.response?.data?.error?.message,
}); httpStatus: axiosError.response?.status,
timestamp: new Date().toISOString(),
},
);
return null; return null;
} }
} }

8
src/libs/fontawesome.ts

@ -86,6 +86,7 @@ import {
faSquareCaretDown, faSquareCaretDown,
faSquareCaretUp, faSquareCaretUp,
faSquarePlus, faSquarePlus,
faStar,
faThumbtack, faThumbtack,
faTrashCan, faTrashCan,
faTriangleExclamation, faTriangleExclamation,
@ -94,6 +95,9 @@ import {
faXmark, faXmark,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
// Initialize Font Awesome library with all required icons // Initialize Font Awesome library with all required icons
library.add( library.add(
faArrowDown, faArrowDown,
@ -168,14 +172,16 @@ library.add(
faPlus, faPlus,
faQrcode, faQrcode,
faQuestion, faQuestion,
faRotate,
faRightFromBracket, faRightFromBracket,
faRotate,
faShareNodes, faShareNodes,
faSpinner, faSpinner,
faSquare, faSquare,
faSquareCaretDown, faSquareCaretDown,
faSquareCaretUp, faSquareCaretUp,
faSquarePlus, faSquarePlus,
faStar,
faStarRegular,
faThumbtack, faThumbtack,
faTrashCan, faTrashCan,
faTriangleExclamation, faTriangleExclamation,

10
src/router/index.ts

@ -285,6 +285,16 @@ const routes: Array<RouteRecordRaw> = [
name: "user-profile", name: "user-profile",
component: () => import("../views/UserProfileView.vue"), component: () => import("../views/UserProfileView.vue"),
}, },
// Catch-all route for 404 errors - must be last
{
path: "/:pathMatch(.*)*",
name: "not-found",
component: () => import("../views/NotFoundView.vue"),
meta: {
title: "Page Not Found",
requiresAuth: false,
},
},
]; ];
const isElectron = window.location.protocol === "file:"; const isElectron = window.location.protocol === "file:";

7
src/services/api.ts

@ -19,7 +19,6 @@ import { logger, safeStringify } from "../utils/logger";
* @remarks * @remarks
* Special handling includes: * Special handling includes:
* - Enhanced logging for Capacitor platform * - Enhanced logging for Capacitor platform
* - Rate limit detection and handling
* - Detailed error information logging including: * - Detailed error information logging including:
* - Error message * - Error message
* - HTTP status * - HTTP status
@ -50,11 +49,5 @@ export const handleApiError = (error: AxiosError, endpoint: string) => {
}); });
} }
// Specific handling for rate limits
if (error.response?.status === 400) {
logger.warn(`[Rate Limit] ${endpoint}`);
return null;
}
throw error; throw error;
}; };

7
src/test/PlatformServiceMixinTest.vue

@ -85,7 +85,6 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component({ @Component({
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
@ -197,10 +196,10 @@ This tests the helper method only - no database interaction`;
const success = await this.$saveSettings(testSettings); const success = await this.$saveSettings(testSettings);
if (success) { if (success) {
// Now query the raw database to see how it's actually stored // Now query the raw database to see how it's actually stored.
// Note that new users probably have settings with ID of 1 but old migrated users might skip to 2.
const rawResult = await this.$dbQuery( const rawResult = await this.$dbQuery(
"SELECT searchBoxes FROM settings WHERE id = ?", "SELECT searchBoxes FROM settings limit 1",
[MASTER_SETTINGS_KEY],
); );
if (rawResult?.values?.length) { if (rawResult?.values?.length) {

26
src/utils/PlatformServiceMixin.ts

@ -301,7 +301,11 @@ export const PlatformServiceMixin = {
} }
// Convert SQLite JSON strings to objects/arrays // Convert SQLite JSON strings to objects/arrays
if (column === "contactMethods" || column === "searchBoxes") { if (
column === "contactMethods" ||
column === "searchBoxes" ||
column === "starredPlanHandleIds"
) {
value = this._parseJsonField(value, []); value = this._parseJsonField(value, []);
} }
@ -349,6 +353,14 @@ export const PlatformServiceMixin = {
? JSON.stringify(settings.searchBoxes) ? JSON.stringify(settings.searchBoxes)
: String(settings.searchBoxes); : String(settings.searchBoxes);
} }
if (settings.starredPlanHandleIds !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(converted as any).starredPlanHandleIds = Array.isArray(
settings.starredPlanHandleIds,
)
? JSON.stringify(settings.starredPlanHandleIds)
: String(settings.starredPlanHandleIds);
}
return converted; return converted;
}, },
@ -551,6 +563,12 @@ export const PlatformServiceMixin = {
if (settings.searchBoxes) { if (settings.searchBoxes) {
settings.searchBoxes = this._parseJsonField(settings.searchBoxes, []); settings.searchBoxes = this._parseJsonField(settings.searchBoxes, []);
} }
if (settings.starredPlanHandleIds) {
settings.starredPlanHandleIds = this._parseJsonField(
settings.starredPlanHandleIds,
[],
);
}
return settings; return settings;
} catch (error) { } catch (error) {
@ -617,6 +635,12 @@ export const PlatformServiceMixin = {
[], [],
); );
} }
if (mergedSettings.starredPlanHandleIds) {
mergedSettings.starredPlanHandleIds = this._parseJsonField(
mergedSettings.starredPlanHandleIds,
[],
);
}
return mergedSettings; return mergedSettings;
} catch (error) { } catch (error) {

35
src/utils/logger.ts

@ -24,10 +24,28 @@ export function getMemoryLogs(): string[] {
return [..._memoryLogs]; return [..._memoryLogs];
} }
/**
* Stringify an object with proper handling of circular references and functions
*
* Don't use for arrays; map with this over the array.
*
* @param obj - The object to stringify
* @returns The stringified object, plus 'message' and 'stack' for Error objects
*/
export function safeStringify(obj: unknown) { export function safeStringify(obj: unknown) {
const seen = new WeakSet(); const seen = new WeakSet();
return JSON.stringify(obj, (_key, value) => { // since 'message' & 'stack' are not enumerable for errors, let's add those
let objToStringify = obj;
if (obj instanceof Error) {
objToStringify = {
...obj,
message: obj.message,
stack: obj.stack,
};
}
return JSON.stringify(objToStringify, (_key, value) => {
if (typeof value === "object" && value !== null) { if (typeof value === "object" && value !== null) {
if (seen.has(value)) { if (seen.has(value)) {
return "[Circular]"; return "[Circular]";
@ -178,7 +196,8 @@ export const logger = {
} }
// Database logging // Database logging
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; const argsString =
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
logToDatabase(message + argsString, "info"); logToDatabase(message + argsString, "info");
}, },
@ -189,7 +208,8 @@ export const logger = {
} }
// Database logging // Database logging
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; const argsString =
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
logToDatabase(message + argsString, "info"); logToDatabase(message + argsString, "info");
}, },
@ -200,7 +220,8 @@ export const logger = {
} }
// Database logging // Database logging
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; const argsString =
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
logToDatabase(message + argsString, "warn"); logToDatabase(message + argsString, "warn");
}, },
@ -211,9 +232,9 @@ export const logger = {
} }
// Database logging // Database logging
const messageString = safeStringify(message); const argsString =
const argsString = args.length > 0 ? safeStringify(args) : ""; args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
logToDatabase(messageString + argsString, "error"); logToDatabase(message + argsString, "error");
}, },
// New database-focused methods (self-contained) // New database-focused methods (self-contained)

80
src/views/AccountViewView.vue

@ -1,6 +1,5 @@
<template> <template>
<QuickNav selected="Profile" /> <QuickNav selected="Profile" />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<main <main
@ -9,10 +8,22 @@
role="main" role="main"
aria-label="Account Profile" aria-label="Account Profile"
> >
<!-- Heading --> <TopMessage />
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Your Identity <!-- Main View Heading -->
</h1> <div class="flex gap-4 items-center mb-8">
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">
Your Identity
</h1>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<!-- ID notice --> <!-- ID notice -->
<div <div
@ -150,8 +161,6 @@
</section> </section>
<PushNotificationPermission ref="pushNotificationPermission" /> <PushNotificationPermission ref="pushNotificationPermission" />
<LocationSearchSection :search-box="searchBox" />
<!-- User Profile --> <!-- User Profile -->
<section <section
v-if="isRegistered" v-if="isRegistered"
@ -244,6 +253,8 @@
<div v-else>Saving...</div> <div v-else>Saving...</div>
</section> </section>
<LocationSearchSection :search-box="searchBox" />
<UsageLimitsSection <UsageLimitsSection
v-if="activeDid" v-if="activeDid"
:loading-limits="loadingLimits" :loading-limits="loadingLimits"
@ -1064,55 +1075,8 @@ export default class AccountViewView extends Vue {
this.hideRegisterPromptOnNewContact = this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact; !!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
this.isSearchAreasSet =
// If settings show unregistered but user has activeDid, verify registration status !!settings.searchBoxes && settings.searchBoxes.length > 0;
if (!this.isRegistered && this.activeDid) {
logger.debug(
"[AccountViewView] Settings show unregistered, verifying with server:",
{
activeDid: this.activeDid,
apiServer: this.apiServer,
},
);
try {
const { fetchEndorserRateLimits } = await import(
"@/libs/endorserServer"
);
const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
this.activeDid,
);
if (resp.status === 200) {
logger.debug(
"[AccountViewView] Server confirms user IS registered, updating settings:",
{
activeDid: this.activeDid,
wasRegistered: false,
nowRegistered: true,
},
);
// Update settings and state
await this.$saveUserSettings(this.activeDid, {
isRegistered: true,
});
this.isRegistered = true;
}
} catch (error) {
logger.debug(
"[AccountViewView] Registration check failed (expected for unregistered users):",
{
activeDid: this.activeDid,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
this.isSearchAreasSet = !!settings.searchBoxes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime; this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || ""; this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.notifyingReminder = !!settings.notifyingReminderTime; this.notifyingReminder = !!settings.notifyingReminderTime;
@ -1126,6 +1090,7 @@ export default class AccountViewView extends Vue {
this.passkeyExpirationMinutes = this.passkeyExpirationMinutes =
settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES; settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES;
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes; this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced; this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.showShortcutBvc = !!settings.showShortcutBvc; this.showShortcutBvc = !!settings.showShortcutBvc;
this.warnIfProdServer = !!settings.warnIfProdServer; this.warnIfProdServer = !!settings.warnIfProdServer;
@ -1511,9 +1476,6 @@ export default class AccountViewView extends Vue {
if (endorserResp.status === 200) { if (endorserResp.status === 200) {
this.endorserLimits = endorserResp.data; this.endorserLimits = endorserResp.data;
} else {
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_LIMITS_FOUND;
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.LIMITS.BAD_SERVER_RESPONSE);
} }
} catch (error) { } catch (error) {
this.limitsMessage = this.limitsMessage =

30
src/views/ClaimAddRawView.vue

@ -1,19 +1,27 @@
<template> <template>
<QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Back -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
Raw Claim Raw Claim
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div class="flex"> <div class="flex">

50
src/views/ClaimView.vue

@ -2,19 +2,27 @@
<QuickNav /> <QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Back -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
aria-label="Go back"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
Verifiable Claim Details Verifiable Claim Details
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Details --> <!-- Details -->
@ -80,16 +88,22 @@
</button> </button>
</div> </div>
</div> </div>
<div class="text-sm"> <div class="text-sm overflow-hidden">
<div data-testId="description"> <div
data-testId="description"
class="overflow-hidden text-ellipsis"
>
<font-awesome icon="message" class="fa-fw text-slate-400" /> <font-awesome icon="message" class="fa-fw text-slate-400" />
{{ claimDescription }} <vue-markdown
:source="claimDescription"
class="markdown-content"
/>
</div> </div>
<div> <div class="overflow-hidden text-ellipsis">
<font-awesome icon="user" class="fa-fw text-slate-400" /> <font-awesome icon="user" class="fa-fw text-slate-400" />
{{ didInfo(veriClaim.issuer) }} {{ didInfo(veriClaim.issuer) }}
</div> </div>
<div> <div class="overflow-hidden text-ellipsis">
<font-awesome icon="calendar" class="fa-fw text-slate-400" /> <font-awesome icon="calendar" class="fa-fw text-slate-400" />
Recorded Recorded
{{ formattedIssueDate }} {{ formattedIssueDate }}
@ -533,8 +547,10 @@ import { AxiosError } from "axios";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { Router, RouteLocationNormalizedLoaded } from "vue-router"; import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService"; import { copyToClipboard } from "../services/ClipboardService";
import { GenericVerifiableCredential } from "../interfaces"; import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue"; import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
@ -553,7 +569,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { APP_SERVER } from "@/constants/app"; import { APP_SERVER } from "@/constants/app";
@Component({ @Component({
components: { GiftedDialog, QuickNav }, components: { GiftedDialog, QuickNav, VueMarkdown },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
export default class ClaimView extends Vue { export default class ClaimView extends Vue {

29
src/views/ConfirmContactView.vue

@ -1,18 +1,27 @@
<template> <template>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Cancel -->
<router-link
:to="{ name: 'account' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Confirm Contact Confirm Contact
</h1> </h1>
<!-- Back -->
<router-link
class="order-first text-lg text-center leading-none p-1"
:to="{ name: 'account' }"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</router-link>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<p class="text-center text-xl mb-4 font-light"> <p class="text-center text-xl mb-4 font-light">

35
src/views/ConfirmGiftView.vue

@ -1,18 +1,11 @@
<template> <template>
<QuickNav />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <TopMessage />
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <!-- Sub View Heading -->
<!-- Back --> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<button <h1 class="grow text-xl text-center font-semibold leading-tight">
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
<span <span
v-if=" v-if="
libsUtil.isGiveRecordTheUserCanConfirm( libsUtil.isGiveRecordTheUserCanConfirm(
@ -25,8 +18,24 @@
> >
Do you agree? Do you agree?
</span> </span>
<span v-else> Confirmation Details </span> <span v-else>Confirmation Details</span>
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div v-if="giveDetails && !isLoading"> <div v-if="giveDetails && !isLoading">

25
src/views/ContactAmountsView.vue

@ -2,18 +2,27 @@
<QuickNav selected="Contacts" /> <QuickNav selected="Contacts" />
<section class="p-6 pb-24 max-w-3xl mx-auto"> <section class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Header --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Transferred with {{ contact?.name }}
</h1>
<!-- Back -->
<router-link <router-link
class="order-first text-lg text-center leading-none p-1"
:to="{ name: 'contacts' }" :to="{ name: 'contacts' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
> >
<font-awesome icon="chevron-left" class="fa-fw" /> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</router-link> </router-link>
<h1 class="text-4xl text-center font-light pt-4"> <!-- Help button -->
Transferred with {{ contact?.name }} <router-link
</h1> :to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Info Messages --> <!-- Info Messages -->
@ -223,7 +232,7 @@ export default class ContactAmountssView extends Vue {
const contact = await this.$getContact(contactDid); const contact = await this.$getContact(contactDid);
this.contact = contact; this.contact = contact;
const settings = await this.$settings(); const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth) // Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

33
src/views/ContactEditView.vue

@ -1,19 +1,28 @@
<template> <template>
<QuickNav selected="Contacts" />
<TopMessage />
<section id="ContactEdit" class="p-6 max-w-3xl mx-auto"> <section id="ContactEdit" class="p-6 max-w-3xl mx-auto">
<div id="ViewBreadcrumb" class="mb-8"> <TopMessage />
<h1 class="text-4xl text-center font-light relative px-7">
<!-- Back --> <!-- Sub View Heading -->
<button <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" <h1 class="grow text-xl text-center font-semibold leading-tight">
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
{{ contact?.name || AppString.NO_CONTACT_NAME }} {{ contact?.name || AppString.NO_CONTACT_NAME }}
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Contact Name --> <!-- Contact Name -->

28
src/views/ContactGiftingView.vue

@ -2,17 +2,27 @@
<QuickNav selected="Home"></QuickNav> <QuickNav selected="Home"></QuickNav>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-2xl text-center font-semibold relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Back -->
<router-link
:to="{ name: 'home' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
{{ stepType === "giver" ? "Given by..." : "Given to..." }} {{ stepType === "giver" ? "Given by..." : "Given to..." }}
</h1> </h1>
<!-- Back -->
<router-link
class="order-first text-lg text-center leading-none p-1"
:to="{ name: 'home' }"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</router-link>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Results List --> <!-- Results List -->

32
src/views/ContactImportView.vue

@ -1,20 +1,28 @@
<template> <template>
<QuickNav selected="Contacts"></QuickNav> <QuickNav selected="Contacts"></QuickNav>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <!-- Sub View Heading -->
<div class="text-lg text-center font-light relative px-7"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 <h1 class="grow text-xl text-center font-semibold leading-tight">
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" Contact Import
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1> </h1>
</div>
<!-- Heading --> <!-- Back -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <a
Contact Import class="order-first text-lg text-center leading-none p-1"
</h1> @click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<div v-if="checkingImports" class="text-center"> <div v-if="checkingImports" class="text-center">
<font-awesome icon="spinner" class="animate-spin" /> <font-awesome icon="spinner" class="animate-spin" />

39
src/views/ContactQRScanFullView.vue

@ -2,26 +2,27 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="relative w-[100vw] h-[100vh]"> <section id="Content" class="relative w-[100vw] h-[100vh]">
<div :class="mainContentClasses"> <div :class="mainContentClasses">
<div class="mb-4"> <!-- Sub View Heading -->
<h1 class="text-xl text-center font-semibold relative"> <div id="SubViewHeading" class="flex gap-4 items-start mb-4">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<a
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="handleBack"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</a>
<!-- Quick Help -->
<a
class="text-xl text-center text-blue-500 px-2 py-1 absolute -right-2 -top-1"
@click="toastQRCodeHelp()"
>
<font-awesome icon="circle-question" class="fa-fw" />
</a>
Share Contact Info Share Contact Info
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="handleBack"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Quick Help -->
<a
class="block text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="toastQRCodeHelp()"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</a>
</div> </div>
<div <div
@ -235,7 +236,7 @@ export default class ContactQRScanFull extends Vue {
* Computed property for main content container CSS classes * Computed property for main content container CSS classes
*/ */
get mainContentClasses(): string { get mainContentClasses(): string {
return "p-6 bg-white w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto"; return "p-4 bg-white w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto";
} }
/** /**

37
src/views/ContactQRScanShowView.vue

@ -1,26 +1,27 @@
<template> <template>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<div class="mb-2"> <!-- Sub View Heading -->
<h1 class="text-2xl text-center font-semibold relative px-7"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<a
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="handleBack"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</a>
<!-- Quick Help -->
<a
class="text-2xl text-center text-blue-500 px-2 py-1 absolute -right-2 -top-1"
@click="toastQRCodeHelp()"
>
<font-awesome icon="circle-question" class="fa-fw" />
</a>
Share Contact Info Share Contact Info
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="handleBack"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Quick Help -->
<a
class="block text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="toastQRCodeHelp()"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</a>
</div> </div>
<div v-if="!givenName" :class="nameWarningClasses"> <div v-if="!givenName" :class="nameWarningClasses">

23
src/views/ContactsView.vue

@ -1,14 +1,25 @@
<template> <template>
<QuickNav selected="Contacts" /> <QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <TopMessage />
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Your Contacts <!-- Main View Heading -->
</h1> <div class="flex gap-4 items-center mb-4">
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">
Your Contacts
</h1>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<div class="flex justify-between py-2 mt-8"> <div class="flex justify-between py-2 mt-4">
<span /> <span />
<span> <span>
<a <a

35
src/views/DIDView.vue

@ -1,22 +1,31 @@
<template> <template>
<QuickNav selected="Contacts" /> <QuickNav selected="Contacts" />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <TopMessage />
<div id="ViewBreadcrumb" class="mb-8">
<h1 id="ViewHeading" class="text-lg text-center font-light relative px-7"> <!-- Sub View Heading -->
<!-- Go to 'contacts' instead of just 'back' because they could get here from an edit page <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
(and going back there is annoying). --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<router-link
:to="{ name: 'contacts' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Identifier Details Identifier Details
</h1> </h1>
<!-- Back -->
<router-link
class="order-first text-lg text-center leading-none p-1"
:to="{ name: 'contacts' }"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</router-link>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Identity Details --> <!-- Identity Details -->
@ -25,7 +34,7 @@
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4" class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
> >
<div> <div>
<h2 class="text-xl font-semibold"> <h2 class="text-xl font-semibold overflow-hidden text-ellipsis">
{{ contactFromDid?.name || "(no name)" }} {{ contactFromDid?.name || "(no name)" }}
<router-link <router-link
:to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }" :to="{ name: 'contact-edit', params: { did: contactFromDid?.did } }"

15
src/views/DeepLinkErrorView.vue

@ -1,7 +1,15 @@
<template> <template>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<h1>Invalid Deep Link</h1> <!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1
class="grow text-rose-500 text-xl text-center font-semibold leading-tight"
>
Invalid Deep Link
</h1>
</div>
<div class="error-details"> <div class="error-details">
<div class="error-message"> <div class="error-message">
<h3>Error Details</h3> <h3>Error Details</h3>
@ -114,11 +122,6 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
h1 {
color: #ff4444;
margin-bottom: 24px;
}
h2, h2,
h3 { h3 {
color: #333; color: #333;

9
src/views/DeepLinkRedirectView.vue

@ -2,9 +2,12 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<div class="mb-4"> <div class="mb-4">
<h1 class="text-2xl text-center font-semibold relative px-7"> <!-- Sub View Heading -->
Redirecting to Time Safari <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
</h1> <h1 class="grow text-xl text-center font-semibold leading-tight">
Redirecting to Time Safari
</h1>
</div>
<div v-if="destinationUrl" class="space-y-4"> <div v-if="destinationUrl" class="space-y-4">
<!-- Platform-specific messaging --> <!-- Platform-specific messaging -->

155
src/views/DiscoverView.vue

@ -1,13 +1,22 @@
<template> <template>
<QuickNav selected="Discover" /> <QuickNav selected="Discover" />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <TopMessage />
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Discover Projects & People <!-- Main View Heading -->
</h1> <div class="flex gap-4 items-center mb-4">
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">Discover</h1>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<OnboardingDialog ref="onboardingDialog" /> <OnboardingDialog ref="onboardingDialog" />
@ -51,6 +60,33 @@
<!-- Secondary Tabs --> <!-- Secondary Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300"> <div class="text-center text-slate-500 border-b border-slate-300">
<ul class="flex flex-wrap justify-center gap-4 -mb-px"> <ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li v-if="isProjectsActive">
<a
href="#"
:class="computedStarredTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
isStarredActive = true;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = false;
isSearchVisible = false;
tempSearchBox = null;
searchStarred();
"
>
Starred
<!-- restore when the links don't jump around for different numbers
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
v-if="isLocalActive"
>
{{ localCount > -1 ? localCount : "?" }}
</span>
-->
</a>
</li>
<li> <li>
<a <a
href="#" href="#"
@ -58,9 +94,11 @@
@click=" @click="
projects = []; projects = [];
userProfiles = []; userProfiles = [];
isStarredActive = false;
isLocalActive = true; isLocalActive = true;
isMappedActive = false; isMappedActive = false;
isAnywhereActive = false; isAnywhereActive = false;
isStarredActive = false;
isSearchVisible = true; isSearchVisible = true;
tempSearchBox = null; tempSearchBox = null;
searchLocal(); searchLocal();
@ -84,9 +122,11 @@
@click=" @click="
projects = []; projects = [];
userProfiles = []; userProfiles = [];
isStarredActive = false;
isLocalActive = false; isLocalActive = false;
isMappedActive = true; isMappedActive = true;
isAnywhereActive = false; isAnywhereActive = false;
isStarredActive = false;
isSearchVisible = false; isSearchVisible = false;
searchTerms = ''; searchTerms = '';
tempSearchBox = null; tempSearchBox = null;
@ -103,9 +143,11 @@
@click=" @click="
projects = []; projects = [];
userProfiles = []; userProfiles = [];
isStarredActive = false;
isLocalActive = false; isLocalActive = false;
isMappedActive = false; isMappedActive = false;
isAnywhereActive = true; isAnywhereActive = true;
isStarredActive = false;
isSearchVisible = true; isSearchVisible = true;
tempSearchBox = null; tempSearchBox = null;
searchAll(); searchAll();
@ -201,6 +243,15 @@
>No {{ isProjectsActive ? "projects" : "people" }} were found with >No {{ isProjectsActive ? "projects" : "people" }} were found with
that search.</span that search.</span
> >
<span v-else-if="isStarredActive">
<p>
You have no starred projects. Star some projects to see them here.
</p>
<p class="mt-4">
When you star projects, you will get a notice on the front page when
they change.
</p>
</span>
</p> </p>
</div> </div>
@ -231,8 +282,8 @@
/> />
</div> </div>
<div class="grow"> <div class="grow overflow-hidden">
<h2 class="text-base font-semibold"> <h2 class="text-base font-semibold truncate">
{{ project.name || unnamedProject }} {{ project.name || unnamedProject }}
</h2> </h2>
<div class="text-sm"> <div class="text-sm">
@ -383,9 +434,12 @@ export default class DiscoverView extends Vue {
allMyDids: Array<string> = []; allMyDids: Array<string> = [];
apiServer = ""; apiServer = "";
isLoading = false; isLoading = false;
isLocalActive = false; isLocalActive = false;
isMappedActive = false; isMappedActive = false;
isAnywhereActive = true; isAnywhereActive = true;
isStarredActive = false;
isProjectsActive = true; isProjectsActive = true;
isPeopleActive = false; isPeopleActive = false;
isSearchVisible = true; isSearchVisible = true;
@ -474,6 +528,8 @@ export default class DiscoverView extends Vue {
leafletObject: L.Map; leafletObject: L.Map;
}; };
this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation
} else if (this.isStarredActive) {
await this.searchStarred();
} else { } else {
await this.searchAll(); await this.searchAll();
} }
@ -544,6 +600,60 @@ export default class DiscoverView extends Vue {
} }
} }
public async searchStarred() {
this.resetCounts();
// Clear any previous results
this.projects = [];
this.userProfiles = [];
try {
this.isLoading = true;
// Get starred project IDs from settings
const settings = await this.$accountSettings();
const starredIds = settings.starredPlanHandleIds || [];
if (starredIds.length === 0) {
// No starred projects
return;
}
// This could be optimized to only pull those not already in the cache (endorserServer.ts)
const planHandleIdsJson = JSON.stringify(starredIds);
const endpoint =
this.apiServer +
"/api/v2/report/plans?planHandleIds=" +
encodeURIComponent(planHandleIdsJson);
const response = await this.axios.get(endpoint, {
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
this.notify.error("Failed to load starred projects", TIMEOUTS.SHORT);
return;
}
const starredPlans: PlanData[] = response.data.data;
if (response.data.hitLimit) {
// someday we'll have to let them incrementally load the rest
this.notify.warning(
"Beware: you have so many starred projects that we cannot load them all.",
TIMEOUTS.SHORT,
);
}
this.projects = starredPlans;
} catch (error: unknown) {
logger.error("Error loading starred projects:", error);
this.notify.error(
"Failed to load starred projects. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.isLoading = false;
}
}
public async searchLocal(beforeId?: string) { public async searchLocal(beforeId?: string) {
this.resetCounts(); this.resetCounts();
@ -637,9 +747,12 @@ export default class DiscoverView extends Vue {
const latestProject = this.projects[this.projects.length - 1]; const latestProject = this.projects[this.projects.length - 1];
if (this.isLocalActive || this.isMappedActive) { if (this.isLocalActive || this.isMappedActive) {
this.searchLocal(latestProject.rowId); this.searchLocal(latestProject.rowId);
} else if (this.isStarredActive) {
this.searchStarred();
} else if (this.isAnywhereActive) { } else if (this.isAnywhereActive) {
this.searchAll(latestProject.rowId); this.searchAll(latestProject.rowId);
} }
// Note: Starred tab doesn't support pagination since we load all starred projects at once
} else if (this.isPeopleActive && this.userProfiles.length > 0) { } else if (this.isPeopleActive && this.userProfiles.length > 0) {
const latestProfile = this.userProfiles[this.userProfiles.length - 1]; const latestProfile = this.userProfiles[this.userProfiles.length - 1];
if (this.isLocalActive || this.isMappedActive) { if (this.isLocalActive || this.isMappedActive) {
@ -779,13 +892,31 @@ export default class DiscoverView extends Vue {
this.$router.push(route); this.$router.push(route);
} }
public computedLocalTabStyleClassNames() { public computedStarredTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-3": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
active: this.isStarredActive,
"text-black": this.isStarredActive,
"border-black": this.isStarredActive,
"font-semibold": this.isStarredActive,
"text-blue-600": !this.isStarredActive,
"border-transparent": !this.isStarredActive,
"hover:border-slate-400": !this.isStarredActive,
};
}
public computedLocalTabStyleClassNames() {
return {
"inline-block": true,
"py-2": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isLocalActive, active: this.isLocalActive,
"text-black": this.isLocalActive, "text-black": this.isLocalActive,
"border-black": this.isLocalActive, "border-black": this.isLocalActive,
@ -800,7 +931,7 @@ export default class DiscoverView extends Vue {
public computedMappedTabStyleClassNames() { public computedMappedTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-2": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
@ -818,7 +949,7 @@ export default class DiscoverView extends Vue {
public computedRemoteTabStyleClassNames() { public computedRemoteTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-2": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
@ -836,7 +967,7 @@ export default class DiscoverView extends Vue {
public computedProjectsTabStyleClassNames() { public computedProjectsTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-2": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
@ -854,7 +985,7 @@ export default class DiscoverView extends Vue {
public computedPeopleTabStyleClassNames() { public computedPeopleTabStyleClassNames() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-2": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,

37
src/views/GiftedDetailsView.vue

@ -1,24 +1,33 @@
<template> <template>
<QuickNav />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <TopMessage />
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-2xl text-center font-semibold relative px-7 mb-2"> <div class="mb-8">
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-4">
<h1 class="grow text-xl text-center font-semibold leading-tight">
What Was Given
</h1>
<!-- Back --> <!-- Back -->
<div <a
v-if="!hideBackButton" class="order-first text-lg text-center leading-none p-1"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="cancelBack()" @click="cancelBack()"
> >
<font-awesome icon="chevron-left" class="fa-fw" /> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</div> </a>
What Was Given
</h1> <!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<h2 class="text-lg font-normal text-center overflow-hidden"> <h2 class="text-lg font-normal leading-tight text-center overflow-hidden">
<div class="truncate"> <div class="truncate">
From From
{{ {{

33
src/views/HelpNotificationTypesView.vue

@ -3,22 +3,27 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<div 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="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Notification Types Notification Types
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- eslint-disable prettier/prettier --> <!-- eslint-disable prettier/prettier -->

33
src/views/HelpNotificationsView.vue

@ -34,22 +34,27 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<div 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="goBack()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Notification Help Notification Help
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="goBack()"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- eslint-disable prettier/prettier --> <!-- eslint-disable prettier/prettier -->

8
src/views/HelpOnboardingView.vue

@ -3,11 +3,9 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Don't include 'back' button since this is shown in a different window. --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Time Safari Onboarding Instructions Time Safari Onboarding Instructions
</h1> </h1>
</div> </div>

32
src/views/HelpView.vue

@ -3,23 +3,25 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<div 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="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Help Help
<span class="text-xs text-gray-500">{{ package.version }}</span> <span class="text-xs font-medium text-slate-500 uppercase">{{
package.version
}}</span>
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Spacer (no Help button) -->
<div class="p-3 pe-3.5 pb-3.5"></div>
</div> </div>
<!-- eslint-disable prettier/prettier max-len --> <!-- eslint-disable prettier/prettier max-len -->

129
src/views/HomeView.vue

@ -6,7 +6,6 @@ Raymer * @version 1.0.0 */
<template> <template>
<QuickNav selected="Home" /> <QuickNav selected="Home" />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section <section
@ -14,10 +13,25 @@ Raymer * @version 1.0.0 */
class="p-6 pb-24 max-w-3xl mx-auto" class="p-6 pb-24 max-w-3xl mx-auto"
:data-active-did="activeDid" :data-active-did="activeDid"
> >
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8"> <TopMessage />
{{ AppString.APP_NAME }}
<span class="text-xs text-gray-500">{{ package.version }}</span> <!-- Main View Heading -->
</h1> <div class="flex gap-4 items-center mb-4">
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">
{{ AppString.APP_NAME }}
<span class="text-xs font-medium text-slate-500 uppercase">{{
package.version
}}</span>
</h1>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<OnboardingDialog ref="onboardingDialog" /> <OnboardingDialog ref="onboardingDialog" />
@ -97,9 +111,9 @@ Raymer * @version 1.0.0 */
<!-- Record Quick-Action --> <!-- Record Quick-Action -->
<div class="mb-6"> <div class="mb-6">
<div class="flex gap-2 items-center mb-2"> <div class="flex gap-2 items-center mb-2">
<h2 class="text-xl font-bold">Record something given by:</h2> <h2 class="font-bold">Record something given by:</h2>
<button <button
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full" class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openGiftedPrompts()" @click="openGiftedPrompts()"
> >
<font-awesome <font-awesome
@ -143,10 +157,10 @@ Raymer * @version 1.0.0 */
<!-- Results List --> <!-- Results List -->
<div class="mt-4 mb-4"> <div class="mt-4 mb-4">
<div class="flex gap-2 items-center mb-3"> <div class="flex gap-2 items-center mb-3">
<h2 class="text-xl font-bold">Latest Activity</h2> <h2 class="font-bold">Latest Activity</h2>
<button <button
v-if="resultsAreFiltered()" v-if="resultsAreFiltered()"
class="block ms-auto text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full" class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openFeedFilters()" @click="openFeedFilters()"
> >
<font-awesome <font-awesome
@ -156,7 +170,7 @@ Raymer * @version 1.0.0 */
</button> </button>
<button <button
v-else v-else
class="block ms-auto text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-2 rounded-full" class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openFeedFilters()" @click="openFeedFilters()"
> >
<font-awesome <font-awesome
@ -170,10 +184,10 @@ Raymer * @version 1.0.0 */
class="border-t p-2 border-slate-300" class="border-t p-2 border-slate-300"
@click="goToActivityToUserPage()" @click="goToActivityToUserPage()"
> >
<div class="flex justify-center"> <div class="flex justify-center gap-2">
<div <div
v-if="numNewOffersToUser" v-if="numNewOffersToUser"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white" class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
> >
<span <span
class="block text-center text-6xl" class="block text-center text-6xl"
@ -187,7 +201,7 @@ Raymer * @version 1.0.0 */
</div> </div>
<div <div
v-if="numNewOffersToUserProjects" v-if="numNewOffersToUserProjects"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white" class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
> >
<span <span
class="block text-center text-6xl" class="block text-center text-6xl"
@ -201,6 +215,22 @@ Raymer * @version 1.0.0 */
projects projects
</p> </p>
</div> </div>
<div
v-if="numNewStarredProjectChanges"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
data-testId="newStarredProjectChangesActivityNumber"
>
{{ numNewStarredProjectChanges
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
</span>
<p class="text-center">
starred project{{ numNewStarredProjectChanges === 1 ? "" : "s" }}
with changes
</p>
</div>
</div> </div>
<div class="flex justify-end mt-2"> <div class="flex justify-end mt-2">
<button class="text-blue-500">View All New Activity For You</button> <button class="text-blue-500">View All New Activity For You</button>
@ -268,6 +298,7 @@ import {
getHeaders, getHeaders,
getNewOffersToUser, getNewOffersToUser,
getNewOffersToUserProjects, getNewOffersToUserProjects,
getStarredProjectsWithChanges,
getPlanFromCache, getPlanFromCache,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { import {
@ -284,6 +315,7 @@ import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications";
import * as Package from "../../package.json"; import * as Package from "../../package.json";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { errorStringForLog } from "../libs/endorserServer"; import { errorStringForLog } from "../libs/endorserServer";
import * as databaseUtil from "../db/databaseUtil";
// consolidate this with GiveActionClaim in src/interfaces/claims.ts // consolidate this with GiveActionClaim in src/interfaces/claims.ts
interface Claim { interface Claim {
@ -396,11 +428,25 @@ export default class HomeView extends Vue {
isRegistered = false; isRegistered = false;
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred project changes that they've acknowledged seeing
newOffersToUserHitLimit: boolean = false; newOffersToUserHitLimit: boolean = false;
newOffersToUserProjectsHitLimit: boolean = false; newOffersToUserProjectsHitLimit: boolean = false;
newStarredProjectChangesHitLimit: boolean = false;
numNewOffersToUser: number = 0; // number of new offers-to-user numNewOffersToUser: number = 0; // number of new offers-to-user
numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects
numNewStarredProjectChanges: number = 0; // number of new starred project changes
starredPlanHandleIds: Array<string> = []; // list of starred project IDs
searchBoxes: Array<{
name: string;
bbox: BoundingBox;
}> = [];
showShortcutBvc = false;
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
selectedImage = "";
isImageViewerOpen = false;
showProjectsDialog = false;
/** /**
* CRITICAL VUE REACTIVITY BUG WORKAROUND * CRITICAL VUE REACTIVITY BUG WORKAROUND
* *
@ -438,16 +484,6 @@ export default class HomeView extends Vue {
// return shouldShow; // return shouldShow;
// } // }
searchBoxes: Array<{
name: string;
bbox: BoundingBox;
}> = [];
showShortcutBvc = false;
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
selectedImage = "";
isImageViewerOpen = false;
showProjectsDialog = false;
/** /**
* Initializes notification helpers * Initializes notification helpers
*/ */
@ -498,6 +534,7 @@ export default class HomeView extends Vue {
this.numNewOffersToUser + this.numNewOffersToUserProjects > 0, this.numNewOffersToUser + this.numNewOffersToUserProjects > 0,
}); });
await this.loadNewStarredProjectChanges();
await this.checkOnboarding(); await this.checkOnboarding();
logger.debug("[HomeView] mounted() - component lifecycle completed", { logger.debug("[HomeView] mounted() - component lifecycle completed", {
@ -623,8 +660,14 @@ export default class HomeView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId; this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId = this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId; settings.lastAckedOfferToUserProjectsJwtId;
this.lastAckedStarredPlanChangesJwtId =
settings.lastAckedStarredPlanChangesJwtId;
this.searchBoxes = settings.searchBoxes || []; this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc; this.showShortcutBvc = !!settings.showShortcutBvc;
this.starredPlanHandleIds = databaseUtil.parseJsonField(
settings.starredPlanHandleIds,
[],
);
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings); this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
// Check onboarding status // Check onboarding status
@ -705,7 +748,7 @@ export default class HomeView extends Vue {
* Used for displaying contact info in feed and actions * Used for displaying contact info in feed and actions
* *
* @internal * @internal
* Called by mounted() and initializeIdentity() * Called by initializeIdentity()
*/ */
private async loadContacts() { private async loadContacts() {
this.allContacts = await this.$contacts(); this.allContacts = await this.$contacts();
@ -719,7 +762,6 @@ export default class HomeView extends Vue {
* Triggers updateAllFeed() to populate activity feed * Triggers updateAllFeed() to populate activity feed
* *
* @internal * @internal
* Called by mounted()
*/ */
private async loadFeedData() { private async loadFeedData() {
await this.updateAllFeed(); await this.updateAllFeed();
@ -733,7 +775,6 @@ export default class HomeView extends Vue {
* - Rate limit status for both * - Rate limit status for both
* *
* @internal * @internal
* Called by mounted() and initializeIdentity()
* @requires Active DID * @requires Active DID
*/ */
private async loadNewOffers() { private async loadNewOffers() {
@ -837,6 +878,42 @@ export default class HomeView extends Vue {
} }
} }
/**
* Loads new changes for starred projects
* Updates:
* - Number of new starred project changes
* - Rate limit status for starred project changes
*
* @internal
* @requires Active DID
*/
private async loadNewStarredProjectChanges() {
if (this.activeDid && this.starredPlanHandleIds.length > 0) {
try {
const starredProjectChanges = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature
logger.warn(
"[HomeView] Failed to load starred project changes:",
error,
);
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
} else {
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
}
/** /**
* Checks if user needs onboarding using ultra-concise mixin utilities * Checks if user needs onboarding using ultra-concise mixin utilities
* Opens onboarding dialog if not completed * Opens onboarding dialog if not completed

29
src/views/IdentitySwitcherView.vue

@ -1,18 +1,27 @@
<template> <template>
<QuickNav selected="Profile"></QuickNav> <QuickNav selected="Profile"></QuickNav>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Cancel -->
<router-link
:to="{ name: 'account' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Switch Identity Switch Identity
</h1> </h1>
<!-- Back -->
<router-link
class="order-first text-lg text-center leading-none p-1"
:to="{ name: 'account' }"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</router-link>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Identity List --> <!-- Identity List -->

29
src/views/ImportAccountView.vue

@ -1,17 +1,26 @@
<template> <template>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Cancel -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left"></font-awesome>
</button>
Import Existing Identifier Import Existing Identifier
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Import Account Form --> <!-- Import Account Form -->
<p class="text-center text-xl mb-4 font-light"> <p class="text-center text-xl mb-4 font-light">

31
src/views/ImportDerivedAccountView.vue

@ -1,20 +1,29 @@
<template> <template>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Cancel -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left"></font-awesome>
</button>
Derive from Existing Identity Derive from Existing Identity
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Import Account Form -->
<!-- Import Account Form -->
<div> <div>
<p class="text-center text-xl mb-4 font-light"> <p class="text-center text-xl mb-4 font-light">
Will increment the maximum known derivation path from the existing seed. Will increment the maximum known derivation path from the existing seed.

35
src/views/InviteOneView.vue

@ -1,20 +1,31 @@
<template> <template>
<QuickNav selected="Invite" /> <QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <TopMessage />
<div class="text-lg text-center font-light relative px-7">
<h1 <!-- Sub View Heading -->
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
@click="$router.back()" <h1 class="grow text-xl text-center font-semibold leading-tight">
> Invitations
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1> </h1>
</div>
<!-- Heading --> <!-- Back -->
<h1 class="text-4xl text-center font-light">Invitations</h1> <a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<ul class="ml-8 mt-4 list-outside list-disc w-5/6"> <ul class="ml-8 mt-4 list-outside list-disc w-5/6">
<li> <li>

31
src/views/LogView.vue

@ -1,22 +1,31 @@
<!-- This is useful in an environment where the download doesn't work. --> <!-- This is useful in an environment where the download doesn't work. -->
<template> <template>
<QuickNav selected="" /> <QuickNav selected="" />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back Button --> <TopMessage />
<div class="relative px-7">
<h1 <!-- Sub View Heading -->
class="text-lg text-center font-light px-2 py-1 absolute -left-2 -top-1" <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
@click="$router.back()" <h1 class="grow text-xl text-center font-semibold leading-tight">Logs</h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
> >
<font-awesome icon="chevron-left" class="mr-2" /> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</h1> </a>
</div>
<!-- Heading --> <!-- Help button -->
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-6">Logs</h1> <router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<!-- Error Message --> <!-- Error Message -->
<div <div

629
src/views/NewActivityView.vue

@ -2,17 +2,27 @@
<QuickNav selected="Home"></QuickNav> <QuickNav selected="Home"></QuickNav>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Back -->
<font-awesome
icon="chevron-left"
class="fa-fw text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
/>
New Activity For You New Activity For You
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Display a single row with the name of "New Offers To You" with a count. --> <!-- Display a single row with the name of "New Offers To You" with a count. -->
@ -29,7 +39,7 @@
v-if="newOffersToUser.length > 0" v-if="newOffersToUser.length > 0"
:icon="showOffersDetails ? 'chevron-down' : 'chevron-right'" :icon="showOffersDetails ? 'chevron-down' : 'chevron-right'"
class="cursor-pointer ml-4 mr-4 text-lg" class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandOffersToUserAndMarkRead()" @click.prevent="expandOffersToUserAndMarkRead()"
/> />
</div> </div>
<a class="text-blue-500 cursor-pointer" @click="handleSeeAllOffersToUser"> <a class="text-blue-500 cursor-pointer" @click="handleSeeAllOffersToUser">
@ -48,7 +58,7 @@
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts)
}}</span> }}</span>
offered offered
<span v-if="offer.objectDescription">{{ <span v-if="offer.objectDescription" class="truncate">{{
offer.objectDescription offer.objectDescription
}}</span }}</span
>{{ offer.objectDescription && offer.amount ? ", and " : "" }} >{{ offer.objectDescription && offer.amount ? ", and " : "" }}
@ -67,10 +77,10 @@
<!-- New line that appears on hover or when the offer is clicked --> <!-- New line that appears on hover or when the offer is clicked -->
<div <div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center" class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="markOffersAsReadStartingWith(offer.jwtId)" @click.prevent="markOffersAsReadStartingWith(offer.jwtId)"
> >
<span class="inline-block w-8 h-px bg-gray-500 mr-2" /> <span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers Click to keep all above as unread offers
</div> </div>
</li> </li>
</ul> </ul>
@ -96,7 +106,7 @@
showOffersToUserProjectsDetails ? 'chevron-down' : 'chevron-right' showOffersToUserProjectsDetails ? 'chevron-down' : 'chevron-right'
" "
class="cursor-pointer ml-4 mr-4 text-lg" class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandOffersToUserProjectsAndMarkRead()" @click.prevent="expandOffersToUserProjectsAndMarkRead()"
/> />
</div> </div>
<a <a
@ -118,7 +128,7 @@
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts) didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts)
}}</span> }}</span>
offered offered
<span v-if="offer.objectDescription">{{ <span v-if="offer.objectDescription" class="truncate">{{
offer.objectDescription offer.objectDescription
}}</span }}</span
>{{ offer.objectDescription && offer.amount ? ", and " : "" }} >{{ offer.objectDescription && offer.amount ? ", and " : "" }}
@ -139,10 +149,153 @@
<!-- New line that appears on hover --> <!-- New line that appears on hover -->
<div <div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center" class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)" @click.prevent="
markOffersToUserProjectsAsReadStartingWith(offer.jwtId)
"
> >
<span class="inline-block w-8 h-px bg-gray-500 mr-2" /> <span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers Click to keep all above as unread offers
</div>
</li>
</ul>
</div>
<!-- Starred Projects with Changes Section -->
<div
class="flex justify-between mt-6"
data-testId="showStarredProjectChanges"
>
<div>
<span class="text-lg font-medium"
>{{ newStarredProjectChanges.length
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}</span
>
<span class="text-lg font-medium ml-4"
>Starred Project{{
newStarredProjectChanges.length === 1 ? "" : "s"
}}
With Changes</span
>
<font-awesome
v-if="newStarredProjectChanges.length > 0"
:icon="
showStarredProjectChangesDetails ? 'chevron-down' : 'chevron-right'
"
class="cursor-pointer ml-4 mr-4 text-lg"
@click.prevent="expandStarredProjectChangesAndMarkRead()"
/>
</div>
</div>
<div v-if="showStarredProjectChangesDetails" class="ml-4 mt-4">
<ul class="list-disc ml-4">
<li
v-for="projectChange in newStarredProjectChanges"
:key="projectChange.plan.handleId"
class="mt-4 relative group"
>
<div class="flex items-center gap-2">
<div class="flex-1 min-w-0">
<span class="font-medium">{{
projectChange.plan.name || "Unnamed Project"
}}</span>
<span
v-if="projectChange.plan.description"
class="text-gray-600 block truncate"
>
{{ projectChange.plan.description }}
</span>
</div>
<router-link
:to="{
path:
'/project/' + encodeURIComponent(projectChange.plan.handleId),
}"
class="text-blue-500 flex-shrink-0"
>
<font-awesome
icon="file-lines"
class="text-blue-500 cursor-pointer"
/>
</router-link>
</div>
<!-- Show what changed -->
<div
v-if="getPlanDifferences(projectChange.plan.handleId)"
class="text-sm mt-2"
>
<div class="font-medium mb-2">Changes</div>
<div class="overflow-x-auto">
<table
class="w-full text-xs border-collapse border border-gray-300 rounded-lg shadow-sm bg-white"
>
<thead>
<tr class="bg-gray-50">
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
></th>
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
>
Previous
</th>
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
>
Current
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(difference, field) in getPlanDifferences(
projectChange.plan.handleId,
)"
:key="field"
class="hover:bg-gray-50"
>
<td
class="border border-gray-300 px-3 py-2 font-medium text-gray-800 break-words"
>
{{ getDisplayFieldName(field) }}
</td>
<td
class="border border-gray-300 px-3 py-2 text-gray-600 break-words align-top"
>
<vue-markdown
v-if="field === 'description' && difference.old"
:source="formatFieldValue(difference.old)"
class="markdown-content"
/>
<span v-else>{{ formatFieldValue(difference.old) }}</span>
</td>
<td
class="border border-gray-300 px-3 py-2 text-green-700 font-medium break-words align-top"
>
<vue-markdown
v-if="field === 'description' && difference.new"
:source="formatFieldValue(difference.new)"
class="markdown-content"
/>
<span v-else>{{ formatFieldValue(difference.new) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else>The changes did not affect essential project data.</div>
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click.prevent="
markStarredProjectChangesAsReadStartingWith(
projectChange.plan.jwtId!,
)
"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as unread changes
</div> </div>
</li> </li>
</ul> </ul>
@ -152,6 +305,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import GiftedDialog from "../components/GiftedDialog.vue"; import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
@ -162,20 +316,28 @@ import { Router } from "vue-router";
import { import {
OfferSummaryRecord, OfferSummaryRecord,
OfferToPlanSummaryRecord, OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records"; } from "../interfaces/records";
import { import {
didInfo, didInfo,
didInfoOrNobody,
displayAmount, displayAmount,
getNewOffersToUser, getNewOffersToUser,
getNewOffersToUserProjects, getNewOffersToUserProjects,
getStarredProjectsWithChanges,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import * as databaseUtil from "../db/databaseUtil";
import * as R from "ramda";
import { PlanActionClaim } from "../interfaces/claims";
import { GenericCredWrapper } from "@/interfaces";
@Component({ @Component({
components: { GiftedDialog, QuickNav, EntityIcon }, components: { GiftedDialog, QuickNav, EntityIcon, VueMarkdown },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
export default class NewActivityView extends Vue { export default class NewActivityView extends Vue {
@ -189,13 +351,22 @@ export default class NewActivityView extends Vue {
apiServer = ""; apiServer = "";
lastAckedOfferToUserJwtId = ""; lastAckedOfferToUserJwtId = "";
lastAckedOfferToUserProjectsJwtId = ""; lastAckedOfferToUserProjectsJwtId = "";
lastAckedStarredPlanChangesJwtId = "";
newOffersToUser: Array<OfferSummaryRecord> = []; newOffersToUser: Array<OfferSummaryRecord> = [];
newOffersToUserHitLimit = false; newOffersToUserHitLimit = false;
newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = []; newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = [];
newOffersToUserProjectsHitLimit = false; newOffersToUserProjectsHitLimit = false;
newStarredProjectChanges: Array<PlanSummaryAndPreviousClaim> = [];
newStarredProjectChangesHitLimit = false;
starredPlanHandleIds: Array<string> = [];
planDifferences: Record<
string,
Record<string, { old: unknown; new: unknown }>
> = {};
showOffersDetails = false; showOffersDetails = false;
showOffersToUserProjectsDetails = false; showOffersToUserProjectsDetails = false;
showStarredProjectChangesDetails = false;
didInfo = didInfo; didInfo = didInfo;
displayAmount = displayAmount; displayAmount = displayAmount;
@ -214,6 +385,12 @@ export default class NewActivityView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || ""; this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || "";
this.lastAckedOfferToUserProjectsJwtId = this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || ""; settings.lastAckedOfferToUserProjectsJwtId || "";
this.lastAckedStarredPlanChangesJwtId =
settings.lastAckedStarredPlanChangesJwtId || "";
this.starredPlanHandleIds = databaseUtil.parseJsonField(
settings.starredPlanHandleIds,
[],
);
this.allContacts = await this.$getAllContacts(); this.allContacts = await this.$getAllContacts();
@ -237,6 +414,29 @@ export default class NewActivityView extends Vue {
this.newOffersToUserProjects = offersToUserProjectsData.data; this.newOffersToUserProjects = offersToUserProjectsData.data;
this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit; this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit;
// Load starred project changes if user has starred projects
if (this.starredPlanHandleIds.length > 0) {
try {
const starredProjectChangesData = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.newStarredProjectChanges = starredProjectChangesData.data;
this.newStarredProjectChangesHitLimit =
starredProjectChangesData.hitLimit;
// Analyze differences between current plans and previous claims
this.analyzePlanDifferences(this.newStarredProjectChanges);
} catch (error) {
logger.warn("Failed to load starred project changes:", error);
this.newStarredProjectChanges = [];
this.newStarredProjectChangesHitLimit = false;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
logger.error("Error retrieving settings & contacts:", err); logger.error("Error retrieving settings & contacts:", err);
@ -250,13 +450,13 @@ export default class NewActivityView extends Vue {
async expandOffersToUserAndMarkRead() { async expandOffersToUserAndMarkRead() {
this.showOffersDetails = !this.showOffersDetails; this.showOffersDetails = !this.showOffersDetails;
if (this.showOffersDetails && this.newOffersToUser.length > 0) { if (this.showOffersDetails && this.newOffersToUser.length > 0) {
await this.$updateSettings({ await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId, lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId,
}); });
// note that we don't update this.lastAckedOfferToUserJwtId in case they // note that we don't update this.lastAckedOfferToUserJwtId in case they
// later choose the last one to keep the offers as new // later choose the last one to keep the offers as new
this.notify.info( this.notify.info(
"The offers are marked as viewed. Click in the list to keep them as new.", "The offers are marked as read. Click in the list to keep them unread.",
TIMEOUTS.LONG, TIMEOUTS.LONG,
); );
} }
@ -268,12 +468,12 @@ export default class NewActivityView extends Vue {
); );
if (index !== -1 && index < this.newOffersToUser.length - 1) { if (index !== -1 && index < this.newOffersToUser.length - 1) {
// Set to the next offer's jwtId // Set to the next offer's jwtId
await this.$updateSettings({ await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId, lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId,
}); });
} else { } else {
// it's the last entry (or not found), so just keep it the same // it's the last entry (or not found), so just keep it the same
await this.$updateSettings({ await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId, lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId,
}); });
} }
@ -290,14 +490,14 @@ export default class NewActivityView extends Vue {
this.showOffersToUserProjectsDetails && this.showOffersToUserProjectsDetails &&
this.newOffersToUserProjects.length > 0 this.newOffersToUserProjects.length > 0
) { ) {
await this.$updateSettings({ await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId: lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[0].jwtId, this.newOffersToUserProjects[0].jwtId,
}); });
// note that we don't update this.lastAckedOfferToUserProjectsJwtId in case // note that we don't update this.lastAckedOfferToUserProjectsJwtId in case
// they later choose the last one to keep the offers as new // they later choose the last one to keep the offers as new
this.notify.info( this.notify.info(
"The offers are marked as viewed. Click in the list to keep them as new.", "The offers are now marked read. Click in the list to keep them unread.",
TIMEOUTS.LONG, TIMEOUTS.LONG,
); );
} }
@ -309,13 +509,13 @@ export default class NewActivityView extends Vue {
); );
if (index !== -1 && index < this.newOffersToUserProjects.length - 1) { if (index !== -1 && index < this.newOffersToUserProjects.length - 1) {
// Set to the next offer's jwtId // Set to the next offer's jwtId
await this.$updateSettings({ await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId: lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[index + 1].jwtId, this.newOffersToUserProjects[index + 1].jwtId,
}); });
} else { } else {
// it's the last entry (or not found), so just keep it the same // it's the last entry (or not found), so just keep it the same
await this.$updateSettings({ await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId: lastAckedOfferToUserProjectsJwtId:
this.lastAckedOfferToUserProjectsJwtId, this.lastAckedOfferToUserProjectsJwtId,
}); });
@ -333,5 +533,382 @@ export default class NewActivityView extends Vue {
async handleSeeAllOffersToUserProjects() { async handleSeeAllOffersToUserProjects() {
this.$router.push("/recent-offers-to-user-projects"); this.$router.push("/recent-offers-to-user-projects");
} }
async expandStarredProjectChangesAndMarkRead() {
this.showStarredProjectChangesDetails =
!this.showStarredProjectChangesDetails;
if (
this.showStarredProjectChangesDetails &&
this.newStarredProjectChanges.length > 0
) {
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId:
this.newStarredProjectChanges[0].plan.jwtId,
});
this.notify.info(
"The starred project changes are now marked read. Click in the list to keep them unread.",
TIMEOUTS.LONG,
);
}
}
async markStarredProjectChangesAsReadStartingWith(jwtId: string) {
const index = this.newStarredProjectChanges.findIndex(
(change) => change.plan.jwtId === jwtId,
);
if (index !== -1 && index < this.newStarredProjectChanges.length - 1) {
// Set to the next change's jwtId
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId:
this.newStarredProjectChanges[index + 1].plan.jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId: this.lastAckedStarredPlanChangesJwtId,
});
}
this.notify.info(
"All starred project changes above that line are marked as unread.",
TIMEOUTS.STANDARD,
);
}
/**
* Analyzes differences between current plans and their previous claims
*
* Walks through a list of PlanSummaryAndPreviousClaim items and stores the
* differences between the previous claim and the current plan. This method
* extracts the claim from the wrappedClaimBefore object and compares relevant
* fields with the current plan.
*
* @param planChanges Array of PlanSummaryAndPreviousClaim objects to analyze
*/
analyzePlanDifferences(planChanges: Array<PlanSummaryAndPreviousClaim>) {
this.planDifferences = {};
for (const planChange of planChanges) {
const currentPlan: PlanSummaryRecord = planChange.plan;
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
planChange.wrappedClaimBefore;
// Extract the actual claim from the wrapped claim
let previousClaim: PlanActionClaim;
const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
"credentialSubject" in embeddedClaim
) {
// It's a Verifiable Credential
previousClaim =
(embeddedClaim.credentialSubject as PlanActionClaim) || embeddedClaim;
} else {
// It's a direct claim
previousClaim = embeddedClaim;
}
if (!previousClaim || !currentPlan.handleId) {
continue;
}
const differences: Record<string, { old: unknown; new: unknown }> = {};
// Compare name
const normalizedOldName = this.normalizeValueForComparison(
previousClaim.name,
);
const normalizedNewName = this.normalizeValueForComparison(
currentPlan.name,
);
if (!R.equals(normalizedOldName, normalizedNewName)) {
differences.name = {
old: previousClaim.name,
new: currentPlan.name,
};
}
// Compare description
const normalizedOldDescription = this.normalizeValueForComparison(
previousClaim.description,
);
const normalizedNewDescription = this.normalizeValueForComparison(
currentPlan.description,
);
if (!R.equals(normalizedOldDescription, normalizedNewDescription)) {
differences.description = {
old: previousClaim.description,
new: currentPlan.description,
};
}
// Compare location (combine latitude and longitude into one row)
const oldLat = this.normalizeValueForComparison(
previousClaim.location?.geo?.latitude,
);
const oldLon = this.normalizeValueForComparison(
previousClaim.location?.geo?.longitude,
);
const newLat = this.normalizeValueForComparison(currentPlan.locLat);
const newLon = this.normalizeValueForComparison(currentPlan.locLon);
if (!R.equals(oldLat, newLat) || !R.equals(oldLon, newLon)) {
differences.location = {
old: this.formatLocationValue(oldLat, oldLon, true),
new: this.formatLocationValue(newLat, newLon, false),
};
}
// Compare agent (issuer)
const oldAgent = didInfoOrNobody(
previousClaim.agent?.identifier,
this.activeDid,
this.allMyDids,
this.allContacts,
);
const newAgent = didInfoOrNobody(
currentPlan.agentDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
const normalizedOldAgent = this.normalizeValueForComparison(oldAgent);
const normalizedNewAgent = this.normalizeValueForComparison(newAgent);
if (!R.equals(normalizedOldAgent, normalizedNewAgent)) {
differences.agent = {
old: oldAgent,
new: newAgent,
};
}
// Compare start time
const oldStartTime = previousClaim.startTime;
const newStartTime = currentPlan.startTime;
const normalizedOldStartTime =
this.normalizeDateForComparison(oldStartTime);
const normalizedNewStartTime =
this.normalizeDateForComparison(newStartTime);
if (!R.equals(normalizedOldStartTime, normalizedNewStartTime)) {
differences.startTime = {
old: oldStartTime,
new: newStartTime,
};
}
// Compare end time
const oldEndTime = previousClaim.endTime;
const newEndTime = currentPlan.endTime;
const normalizedOldEndTime = this.normalizeDateForComparison(oldEndTime);
const normalizedNewEndTime = this.normalizeDateForComparison(newEndTime);
if (!R.equals(normalizedOldEndTime, normalizedNewEndTime)) {
differences.endTime = {
old: oldEndTime,
new: newEndTime,
};
}
// Compare image
const oldImage = previousClaim.image;
const newImage = currentPlan.image;
const normalizedOldImage = this.normalizeValueForComparison(oldImage);
const normalizedNewImage = this.normalizeValueForComparison(newImage);
if (!R.equals(normalizedOldImage, normalizedNewImage)) {
differences.image = {
old: oldImage,
new: newImage,
};
}
// Compare url
const oldUrl = previousClaim.url;
const newUrl = currentPlan.url;
const normalizedOldUrl = this.normalizeValueForComparison(oldUrl);
const normalizedNewUrl = this.normalizeValueForComparison(newUrl);
if (!R.equals(normalizedOldUrl, normalizedNewUrl)) {
differences.url = {
old: oldUrl,
new: newUrl,
};
}
// Store differences if any were found
if (!R.isEmpty(differences)) {
this.planDifferences[currentPlan.handleId] = differences;
logger.debug(
"[NewActivityView] Plan differences found for",
currentPlan.handleId,
differences,
);
}
}
logger.debug(
"[NewActivityView] Analyzed",
planChanges.length,
"plan changes, found differences in",
Object.keys(this.planDifferences).length,
"plans",
);
}
/**
* Normalizes values for comparison - treats null, undefined, and empty string as equivalent
*
* @param value The value to normalize
* @returns The normalized value (null for null/undefined/empty, otherwise the original value)
*/
normalizeValueForComparison<T>(value: T | null | undefined): T | null {
if (value === null || value === undefined || value === "") {
return null;
}
return value;
}
/**
* Normalizes date values for comparison by converting strings to Date objects
* Returns null for null/undefined/empty values, Date objects for valid date strings
*/
normalizeDateForComparison(value: unknown): Date | null {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value === "string") {
const date = new Date(value);
// Check if the date is valid
return isNaN(date.getTime()) ? null : date;
}
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value;
}
return null;
}
/**
* Gets the differences for a specific plan by handle ID
*
* @param handleId The handle ID of the plan to get differences for
* @returns The differences object or null if no differences found
*/
getPlanDifferences(
handleId: string,
): Record<string, { old: unknown; new: unknown }> | null {
return this.planDifferences[handleId] || null;
}
/**
* Formats a field value for display in the UI
*
* @param value The value to format
* @returns A human-readable string representation
*/
formatFieldValue(value: unknown): string {
if (value === null || value === undefined) {
return "Not set";
}
if (typeof value === "string") {
const stringValue = value || "Empty";
// Check if it's a date/time string
if (this.isDateTimeString(stringValue)) {
return this.formatDateTime(stringValue);
}
// Check if it's a URL
if (this.isUrl(stringValue)) {
return stringValue; // Keep URLs as-is for now
}
return stringValue;
}
if (typeof value === "number") {
return value.toString();
}
if (typeof value === "boolean") {
return value ? "Yes" : "No";
}
// For complex objects, stringify
const stringified = JSON.stringify(value);
return stringified;
}
/**
* Checks if a string appears to be a date/time string
*/
isDateTimeString(value: string): boolean {
if (!value) return false;
// Check for ISO 8601 format or other common date formats
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?(\.\d{3})?Z?$/;
return dateRegex.test(value) || !isNaN(Date.parse(value));
}
/**
* Checks if a string is a URL
*/
isUrl(value: string): boolean {
if (!value) return false;
try {
new URL(value);
return true;
} catch {
return false;
}
}
/**
* Formats a date/time string for display
*/
formatDateTime(value: string): string {
try {
const date = new Date(value);
return date.toLocaleString();
} catch {
return value; // Return original if parsing fails
}
}
/**
* Gets a human-readable field name for display
*
* @param fieldName The internal field name
* @returns A formatted field name for display
*/
getDisplayFieldName(fieldName: string): string {
const fieldNameMap: Record<string, string> = {
name: "Name",
description: "Description",
location: "Location",
agent: "Agent",
startTime: "Start Time",
endTime: "End Time",
image: "Image",
url: "URL",
};
return fieldNameMap[fieldName] || fieldName;
}
/**
* Formats location values for display
*
* @param latitude The latitude value
* @param longitude The longitude value
* @param isOldValue Whether this is the old value (true) or new value (false)
* @returns A formatted location string
*/
formatLocationValue(
latitude: number | undefined | null,
longitude: number | undefined | null,
isOldValue: boolean = false,
): string {
if (latitude == null && longitude == null) {
return "Not set";
}
// If there's any location data, show generic labels instead of coordinates
if (isOldValue) {
return "A Location";
} else {
return "New Location";
}
}
} }
</script> </script>

29
src/views/NewEditAccountView.vue

@ -22,18 +22,27 @@
--> -->
<template> <template>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Cancel -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Edit Identity Edit Identity
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<input <input

30
src/views/NewEditProjectView.vue

@ -2,19 +2,27 @@
<QuickNav selected="Projects"></QuickNav> <QuickNav selected="Projects"></QuickNav>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Cancel -->
<!-- Back -->
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Edit Project Idea Edit Project Idea
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Project Details --> <!-- Project Details -->

33
src/views/NewIdentifierView.vue

@ -3,22 +3,27 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<div 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="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Your Identity Your Identity
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div class="flex justify-center py-12"> <div class="flex justify-center py-12">

88
src/views/NotFoundView.vue

@ -0,0 +1,88 @@
<template>
<div
class="min-h-screen bg-gray-50 flex flex-col justify-start pt-2 sm:px-6 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="text-center">
<div class="mx-auto h-24 w-24 text-gray-400">
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="10" stroke-width="1.5" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01"
/>
</svg>
</div>
<h1 class="mt-4 text-3xl font-extrabold text-gray-900">Not Found</h1>
<p class="text-sm text-gray-600">
The page you're looking for doesn't exist.
</p>
<div class="mt-1">
<button
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@click="goBack"
>
<svg
class="-ml-1 mr-2 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Go Back
</button>
</div>
<div class="mt-16">
<button
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@click="goHome"
>
<svg
class="-ml-1 mr-2 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2
2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1
1 0 011 1v4a1 1 0 001 1m-6 0h6"
></path>
</svg>
Go Home
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
const router = useRouter();
const goHome = () => {
router.push("/");
};
const goBack = () => {
router.go(-1);
};
</script>

34
src/views/OfferDetailsView.vue

@ -1,24 +1,32 @@
<template> <template>
<QuickNav /> <QuickNav />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <TopMessage />
<div
v-if="!hideBackButton" <!-- Sub View Heading -->
class="text-lg text-center font-light relative px-7" <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
> <h1 class="grow text-xl text-center font-semibold leading-tight">
<h1 What Is Offered
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="cancelBack()" @click="cancelBack()"
> >
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</h1> </a>
</div>
<!-- Heading --> <!-- Help button -->
<h1 class="text-4xl text-center font-light px-4 mb-4">What Is Offered</h1> <router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<h1 class="text-xl font-bold text-center mb-4"> <h1 class="text-xl font-bold text-center mb-4">
<span> Offer to {{ recipientDisplayName }} </span> <span> Offer to {{ recipientDisplayName }} </span>

24
src/views/OnboardMeetingListView.vue

@ -1,12 +1,26 @@
<template> <template>
<QuickNav selected="Contacts" /> <QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <TopMessage />
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
Onboarding Meetings <!-- Sub View Heading -->
</h1> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Onboarding Meetings
</h1>
<!-- Spacer (no Back button) -->
<div class="order-first p-3 pe-3.5 pb-3.5"></div>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<!-- Loading State --> <!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8"> <div v-if="isLoading" class="flex justify-center items-center py-8">

26
src/views/OnboardMeetingMembersView.vue

@ -1,12 +1,26 @@
<template> <template>
<QuickNav selected="Contacts" /> <QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <TopMessage />
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
Meeting Members <!-- Sub View Heading -->
</h1> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Meeting Members
</h1>
<!-- Spacer (no Back button) -->
<div class="order-first p-3 pe-3.5 pb-3.5"></div>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<!-- Loading Animation --> <!-- Loading Animation -->
<div <div
@ -63,6 +77,7 @@ import {
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { generateSaveAndActivateIdentity } from "../libs/util"; import { generateSaveAndActivateIdentity } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { NotificationIface } from "../constants/app";
@Component({ @Component({
components: { components: {
@ -83,6 +98,7 @@ export default class OnboardMeetingMembersView extends Vue {
projectLink = ""; projectLink = "";
$route!: RouteLocationNormalizedLoaded; $route!: RouteLocationNormalizedLoaded;
$router!: Router; $router!: Router;
$notify!: (notification: NotificationIface, timeout?: number) => void;
userNameDialog!: InstanceType<typeof UserNameDialog>; userNameDialog!: InstanceType<typeof UserNameDialog>;

64
src/views/OnboardMeetingSetupView.vue

@ -1,12 +1,26 @@
<template> <template>
<QuickNav selected="Contacts" /> <QuickNav selected="Contacts" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <TopMessage />
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Onboarding Meeting <!-- Sub View Heading -->
</h1> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Onboarding Meeting
</h1>
<!-- Spacer (no Back button) -->
<div class="order-first p-3 pe-3.5 pb-3.5"></div>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<!-- Existing Meeting Section --> <!-- Existing Meeting Section -->
<div <div
@ -216,26 +230,28 @@
class="mt-8 p-4 border rounded-lg bg-white shadow" class="mt-8 p-4 border rounded-lg bg-white shadow"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-2xl">Meeting Members</h2> <h2 class="font-bold">Meeting Members</h2>
</div>
<div class="mt-4">
&bull; Page for Members
<span
class="ml-4 cursor-pointer text-blue-600"
title="Click to copy link for members"
@click="copyMembersLinkToClipboard"
>
<font-awesome icon="link" />
</span>
<a
v-if="!!currentMeeting.password"
:href="onboardMeetingMembersLink()"
class="ml-4 text-blue-600"
target="_blank"
>
<font-awesome icon="external-link" />
</a>
</div> </div>
<ul class="list-disc text-sm mt-4 mb-2 ps-4 space-y-2">
<li>
Page for Members:
<span
class="ml-4 cursor-pointer text-blue-600"
title="Click to copy link for members"
@click="copyMembersLinkToClipboard"
>
<font-awesome icon="link" />
</span>
<a
v-if="!!currentMeeting.password"
:href="onboardMeetingMembersLink()"
class="ml-4 text-blue-600"
target="_blank"
>
<font-awesome icon="external-link" />
</a>
</li>
</ul>
<MembersList <MembersList
:password="currentMeeting.password || ''" :password="currentMeeting.password || ''"

212
src/views/ProjectViewView.vue

@ -1,40 +1,58 @@
<template> <template>
<QuickNav /> <QuickNav />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <TopMessage />
<div id="ViewBreadcrumb">
<div> <div class="mb-4">
<h1 class="text-center text-lg font-light relative px-7"> <!-- Sub View Heading -->
<!-- Back --> <div id="SubViewHeading" class="flex gap-4 items-start mb-4">
<button <h1 class="grow text-xl text-center font-semibold leading-tight">
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Project Idea Project Idea
</h1> </h1>
<h2 class="text-center text-xl font-semibold">
{{ name }} <!-- Back -->
<button <a
v-if="activeDid === issuer || activeDid === agentDid" class="order-first text-lg text-center leading-none p-1"
title="Edit" @click="$router.go(-1)"
data-testId="editClaimButton" >
@click="onEditClick()" <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
> </a>
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button> <!-- Help button -->
<button title="Copy Link to Project" @click="onCopyLinkClick()"> <router-link
<font-awesome :to="{ name: 'help' }"
icon="link" class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
class="text-sm text-slate-500 ml-2 mb-1" >
/> <font-awesome icon="question" class="block text-center w-[1em]" />
</button> </router-link>
</h2>
</div> </div>
<h2 class="text-center text-lg font-normal overflow-hidden text-ellipsis">
{{ name }}
<button
v-if="activeDid === issuer || activeDid === agentDid"
title="Edit"
data-testId="editClaimButton"
@click="onEditClick()"
>
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2" />
</button>
<button
:title="
isStarred
? 'Remove from starred projects'
: 'Add to starred projects'
"
@click="toggleStar()"
>
<font-awesome
:icon="isStarred ? 'star' : ['far', 'star']"
:class="isStarred ? 'text-yellow-500' : 'text-slate-500'"
class="text-sm ml-2"
/>
</button>
</h2>
</div> </div>
<!-- Project Details --> <!-- Project Details -->
@ -58,13 +76,13 @@
icon="user" icon="user"
class="fa-fw text-slate-400" class="fa-fw text-slate-400"
></font-awesome> ></font-awesome>
<span class="truncate inline-block max-w-[calc(100%-2rem)]"> <span class="truncate max-w-[calc(100%-2rem)] ml-1">
{{ issuerInfoObject?.displayName }} {{ issuerInfoObject?.displayName }}
</span> </span>
<span <span
v-if="!serverUtil.isHiddenDid(issuer)" v-if="!serverUtil.isHiddenDid(issuer)"
class="inline-flex items-center" class="inline-flex items-center ml-1"
> >
<router-link <router-link
:to="{ :to="{
@ -139,26 +157,30 @@
</div> </div>
<div class="text-sm text-slate-500"> <div class="text-sm text-slate-500">
<div v-if="!expanded"> <div v-if="!expanded" class="overflow-hidden text-ellipsis">
{{ truncatedDesc }} <vue-markdown
:source="truncatedDesc"
class="mb-4 markdown-content"
/>
<a <a
v-if="description.length >= truncateLength" v-if="description.length >= truncateLength"
class="uppercase text-xs font-semibold text-slate-700" class="mt-4 uppercase text-xs font-semibold text-blue-700 cursor-pointer"
@click="expandText" @click="expandText"
>... Read More</a >... Read More</a
> >
</div> </div>
<div v-else> <div v-else class="overflow-hidden text-ellipsis">
{{ description }} <vue-markdown :source="description" class="mb-4 markdown-content" />
<a <a
class="uppercase text-xs font-semibold text-slate-700" v-if="description.length >= truncateLength"
class="mt-4 uppercase text-xs font-semibold text-blue-700 cursor-pointer"
@click="collapseText" @click="collapseText"
>- Read Less</a >- Read Less</a
> >
</div> </div>
</div> </div>
<a class="cursor-pointer" @click="onClickLoadClaim(projectId)"> <a class="cursor-pointer" @click="onClickLoadClaim(jwtId)">
<font-awesome icon="file-lines" class="pl-2 pt-1 text-blue-500" /> <font-awesome icon="file-lines" class="pl-2 pt-1 text-blue-500" />
</a> </a>
</div> </div>
@ -286,15 +308,15 @@
/>{{ offer.amount }} />{{ offer.amount }}
</span> </span>
</div> </div>
<div v-if="offer.objectDescription" class="text-slate-500"> <div
v-if="offer.objectDescription"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" /> <font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ offer.objectDescription }} {{ offer.objectDescription }}
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<a <a class="cursor-pointer" @click="onClickLoadClaim(offer.jwtId)">
class="cursor-pointer"
@click="onClickLoadClaim(offer.jwtId as string)"
>
<font-awesome <font-awesome
icon="file-lines" icon="file-lines"
class="pl-2 pt-1 text-blue-500" class="pl-2 pt-1 text-blue-500"
@ -431,7 +453,10 @@
<font-awesome icon="calendar" class="fa-fw text-slate-400" /> <font-awesome icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }} {{ give.issuedAt?.substring(0, 10) }}
</div> </div>
<div v-if="give.description" class="text-slate-500"> <div
v-if="give.description"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" /> <font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ give.description }} {{ give.description }}
</div> </div>
@ -538,7 +563,10 @@
<font-awesome icon="calendar" class="fa-fw text-slate-400" /> <font-awesome icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }} {{ give.issuedAt?.substring(0, 10) }}
</div> </div>
<div v-if="give.description" class="text-slate-500"> <div
v-if="give.description"
class="text-slate-500 overflow-hidden text-ellipsis"
>
<font-awesome icon="comment" class="fa-fw text-slate-400" /> <font-awesome icon="comment" class="fa-fw text-slate-400" />
{{ give.description }} {{ give.description }}
</div> </div>
@ -592,7 +620,9 @@
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { import {
GenericVerifiableCredential, GenericVerifiableCredential,
GenericCredWrapper, GenericCredWrapper,
@ -603,25 +633,25 @@ import {
PlanSummaryRecord, PlanSummaryRecord,
} from "../interfaces"; } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue"; import GiftedDialog from "../components/GiftedDialog.vue";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import OfferDialog from "../components/OfferDialog.vue"; import OfferDialog from "../components/OfferDialog.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue"; import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue"; import ProjectIcon from "../components/ProjectIcon.vue";
import { NotificationIface } from "../constants/app"; import { APP_SERVER, NotificationIface } from "../constants/app";
// Removed legacy logging import - migrated to PlatformServiceMixin import { UNNAMED_PROJECT } from "../constants/entities";
import { NOTIFY_CONFIRM_CLAIM } from "../constants/notifications";
import * as databaseUtil from "../db/databaseUtil";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger";
import { copyToClipboard } from "../services/ClipboardService"; import { copyToClipboard } from "../services/ClipboardService";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONFIRM_CLAIM } from "@/constants/notifications";
import { APP_SERVER } from "@/constants/app";
import { UNNAMED_PROJECT } from "@/constants/entities";
/** /**
* Project View Component * Project View Component
* @author Matthew Raymer * @author Matthew Raymer
@ -663,6 +693,7 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
ProjectIcon, ProjectIcon,
QuickNav, QuickNav,
TopMessage, TopMessage,
VueMarkdown,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@ -718,6 +749,8 @@ export default class ProjectViewView extends Vue {
givesProvidedByHitLimit = false; givesProvidedByHitLimit = false;
givesTotalsByUnit: Array<{ unit: string; amount: number }> = []; givesTotalsByUnit: Array<{ unit: string; amount: number }> = [];
imageUrl = ""; imageUrl = "";
/** Whether this project is starred by the user */
isStarred = false;
/** Project issuer DID */ /** Project issuer DID */
issuer = ""; issuer = "";
/** Cached issuer information */ /** Cached issuer information */
@ -728,6 +761,8 @@ export default class ProjectViewView extends Vue {
} | null = null; } | null = null;
/** DIDs that can see issuer information */ /** DIDs that can see issuer information */
issuerVisibleToDids: Array<string> = []; issuerVisibleToDids: Array<string> = [];
/** Project JWT ID */
jwtId = "";
/** Project location data */ /** Project location data */
latitude = 0; latitude = 0;
loadingTotals = false; loadingTotals = false;
@ -756,7 +791,7 @@ export default class ProjectViewView extends Vue {
totalsExpanded = false; totalsExpanded = false;
truncatedDesc = ""; truncatedDesc = "";
/** Truncation length */ /** Truncation length */
truncateLength = 40; truncateLength = 200;
// Utility References // Utility References
libsUtil = libsUtil; libsUtil = libsUtil;
@ -810,6 +845,12 @@ export default class ProjectViewView extends Vue {
} }
this.loadProject(this.projectId, this.activeDid); this.loadProject(this.projectId, this.activeDid);
this.loadTotals(); this.loadTotals();
// Check if this project is starred when settings are loaded
if (this.projectId && settings.starredPlanHandleIds) {
const starredIds = settings.starredPlanHandleIds || [];
this.isStarred = starredIds.includes(this.projectId);
}
} }
/** /**
@ -886,8 +927,9 @@ export default class ProjectViewView extends Vue {
this.allContacts, this.allContacts,
); );
this.issuerVisibleToDids = resp.data.issuerVisibleToDids || []; this.issuerVisibleToDids = resp.data.issuerVisibleToDids || [];
this.jwtId = resp.data.id;
this.name = resp.data.claim?.name || "(no name)"; this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)"; this.description = resp.data.claim?.description || "";
this.truncatedDesc = this.description.slice(0, this.truncateLength); this.truncatedDesc = this.description.slice(0, this.truncateLength);
this.latitude = resp.data.claim?.location?.geo?.latitude || 0; this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
this.longitude = resp.data.claim?.location?.geo?.longitude || 0; this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
@ -1477,5 +1519,67 @@ export default class ProjectViewView extends Vue {
this.givesTotalsByUnit.find((total) => total.unit === "HUR")?.amount || 0 this.givesTotalsByUnit.find((total) => total.unit === "HUR")?.amount || 0
); );
} }
/**
* Toggle the starred status of the current project
*/
async toggleStar() {
if (!this.projectId) return;
try {
const settings = await this.$accountSettings();
const starredIds = settings.starredPlanHandleIds || [];
if (!this.isStarred) {
// Add to starred projects
if (!starredIds.includes(this.projectId)) {
const newStarredIds = [...starredIds, this.projectId];
const newIdsParam = JSON.stringify(newStarredIds);
const result = await databaseUtil.updateDidSpecificSettings(
this.activeDid,
// @ts-expect-error until we use SettingsWithJsonString properly
{ starredPlanHandleIds: newIdsParam },
);
if (result) {
this.isStarred = true;
} else {
// eslint-disable-next-line no-console
logger.error("Got a bad result from SQL update to star a project.");
}
}
if (!settings.lastAckedStarredPlanChangesJwtId) {
await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId: this.jwtId,
});
}
} else {
// Remove from starred projects
const updatedIds = starredIds.filter((id) => id !== this.projectId);
const newIdsParam = JSON.stringify(updatedIds);
const result = await databaseUtil.updateDidSpecificSettings(
this.activeDid,
// @ts-expect-error until we use SettingsWithJsonString properly
{ starredPlanHandleIds: newIdsParam },
);
if (result) {
this.isStarred = false;
} else {
// eslint-disable-next-line no-console
logger.error("Got a bad result from SQL update to unstar a project.");
}
}
} catch (error) {
logger.error("Error toggling star status:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to update starred status. Please try again.",
},
3000,
);
}
}
} }
</script> </script>

37
src/views/ProjectsView.vue

@ -1,17 +1,28 @@
<template> <template>
<QuickNav selected="Projects" /> <QuickNav selected="Projects" />
<TopMessage />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <TopMessage />
<h1 id="ViewHeading" class="text-4xl text-center font-light">
Your Project Ideas <!-- Main View Heading -->
</h1> <div class="flex gap-4 items-center mb-4">
<h1 id="ViewHeading" class="text-2xl font-bold leading-none">
Your Ideas
</h1>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<OnboardingDialog ref="onboardingDialog" /> <OnboardingDialog ref="onboardingDialog" />
<!-- Result Tabs --> <!-- Result Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300 mt-8"> <div class="text-center text-slate-500 border-b border-slate-300 mt-4">
<ul class="flex flex-wrap justify-center gap-4 -mb-px"> <ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li> <li>
<a <a
@ -107,8 +118,8 @@
/> />
</div> </div>
<div> <div class="overflow-hidden">
<div> <div class="text-sm truncate">
To To
{{ {{
offer.fulfillsPlanHandleId offer.fulfillsPlanHandleId
@ -121,7 +132,7 @@
) )
}} }}
</div> </div>
<div> <div class="truncate">
{{ offer.objectDescription }} {{ offer.objectDescription }}
</div> </div>
@ -243,7 +254,7 @@
</div> </div>
<div class="grow overflow-hidden"> <div class="grow overflow-hidden">
<h2 class="text-base font-semibold"> <h2 class="text-base font-semibold truncate">
{{ project.name || unnamedProject }} {{ project.name || unnamedProject }}
</h2> </h2>
<div class="text-sm truncate"> <div class="text-sm truncate">
@ -646,7 +657,7 @@ export default class ProjectsView extends Vue {
get offerTabClasses() { get offerTabClasses() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-2": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
active: this.showOffers, active: this.showOffers,
@ -664,7 +675,7 @@ export default class ProjectsView extends Vue {
* @returns String with CSS classes for the floating new project button * @returns String with CSS classes for the floating new project button
*/ */
get newProjectButtonClasses() { get newProjectButtonClasses() {
return "fixed right-6 top-24 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full"; return "fixed right-6 top-14 text-center text-4xl leading-none bg-green-600 text-white w-14 py-2.5 rounded-full";
} }
/** /**
@ -706,7 +717,7 @@ export default class ProjectsView extends Vue {
get projectTabClasses() { get projectTabClasses() {
return { return {
"inline-block": true, "inline-block": true,
"py-3": true, "py-2": true,
"rounded-t-lg": true, "rounded-t-lg": true,
"border-b-2": true, "border-b-2": true,
active: this.showProjects, active: this.showProjects,

33
src/views/QuickActionBvcBeginView.vue

@ -1,23 +1,32 @@
<template> <template>
<QuickNav /> <QuickNav />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <TopMessage />
<div class="text-lg text-center font-light relative px-7">
<h1 <!-- Sub View Heading -->
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Beginning of BVC Saturday Meeting
</h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="goBack()" @click="goBack()"
> >
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</h1> </a>
</div>
<!-- Heading --> <!-- Help button -->
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-4"> <router-link
Beginning of BVC Saturday Meeting :to="{ name: 'help' }"
</h1> class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<div> <div>
<h2 class="text-2xl m-2">You're Here</h2> <h2 class="text-2xl m-2">You're Here</h2>

32
src/views/QuickActionBvcEndView.vue

@ -1,20 +1,32 @@
<template> <template>
<QuickNav /> <QuickNav />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <TopMessage />
<div class="text-lg text-center font-light relative px-7">
<h1 :class="backButtonClasses" @click="$router.back()"> <!-- Sub View Heading -->
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
End of BVC Saturday Meeting
</h1> </h1>
</div>
<!-- Heading --> <!-- Back -->
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-4"> <a
End of BVC Saturday Meeting class="order-first text-lg text-center leading-none p-1"
</h1> @click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<div> <div>
<h2 class="text-2xl m-2">Confirm</h2> <h2 class="text-2xl m-2">Confirm</h2>

35
src/views/QuickActionBvcView.vue

@ -1,23 +1,32 @@
<template> <template>
<QuickNav /> <QuickNav />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <TopMessage />
<div class="text-lg text-center font-light relative px-7">
<h1 <!-- Sub View Heading -->
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
@click="$router.back()" <h1 class="grow text-xl text-center font-semibold leading-tight">
> Bountiful Voluntaryist Community Actions
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1> </h1>
</div>
<!-- Heading --> <!-- Back -->
<h1 id="ViewHeading" class="text-4xl text-center font-light px-4 mb-4"> <a
Bountiful Voluntaryist Community Actions class="order-first text-lg text-center leading-none p-1"
</h1> @click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<div> <div>
<router-link <router-link

28
src/views/RecentOffersToUserProjectsView.vue

@ -2,17 +2,27 @@
<QuickNav selected="Home"></QuickNav> <QuickNav selected="Home"></QuickNav>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Back -->
<font-awesome
icon="chevron-left"
class="fa-fw text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
/>
Offers to Your Projects Offers to Your Projects
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div v-if="newOffersToUserProjects.length === 0"> <div v-if="newOffersToUserProjects.length === 0">

28
src/views/RecentOffersToUserView.vue

@ -2,17 +2,27 @@
<QuickNav selected="Home"></QuickNav> <QuickNav selected="Home"></QuickNav>
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div id="ViewBreadcrumb" class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="text-lg text-center font-light relative px-7"> <h1 class="grow text-xl text-center font-semibold leading-tight">
<!-- Back -->
<font-awesome
icon="chevron-left"
class="fa-fw text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
/>
Offers to You Offers to You
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div v-if="newOffersToUser.length === 0"> <div v-if="newOffersToUser.length === 0">

35
src/views/SearchAreaView.vue

@ -1,24 +1,27 @@
<template> <template>
<QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<div 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="goBack"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Area for Nearby Search Area for Nearby Search
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="goBack()"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div class="px-2 py-4"> <div class="px-2 py-4">

38
src/views/SeedBackupView.vue

@ -28,31 +28,29 @@
--> -->
<template> <template>
<QuickNav selected="Profile" />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back --> <!-- Sub View Heading -->
<div class="text-lg text-center font-light relative px-7"> <div class="flex gap-4 items-start mb-8">
<h1 <h1 class="grow text-xl text-center font-semibold leading-tight">
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" Seed Backup
@click="goBack"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1> </h1>
</div>
<!-- Heading --> <!-- Back -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <a
Seed Backup class="order-first text-lg text-center leading-none p-1"
</h1> @click="goBack()"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<div class="flex justify-between py-2"> <!-- Help button -->
<span /> <router-link
<span> :to="{ name: 'help' }"
<router-link :to="{ name: 'help' }" :class="helpButtonClass"> class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
Help >
</router-link> <font-awesome icon="question" class="block text-center w-[1em]" />
</span> </router-link>
</div> </div>
<div v-if="activeAccount"> <div v-if="activeAccount">

35
src/views/ShareMyContactInfoView.vue

@ -1,26 +1,31 @@
<template> <template>
<QuickNav /> <QuickNav />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-2 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-2 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <TopMessage />
<div>
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<button
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
aria-label="Go back"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</button>
</div>
<!-- Heading --> <!-- Sub View Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Share Your Contact Info Share Your Contact Info
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div class="flex justify-center mt-8"> <div class="flex justify-center mt-8">

26
src/views/SharedPhotoView.vue

@ -39,10 +39,28 @@
<QuickNav /> <QuickNav />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Heading --> <!-- Sub View Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
Image <h1 class="grow text-xl text-center font-semibold leading-tight">
</h1> Image
</h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<div v-if="imageBlob"> <div v-if="imageBlob">
<div v-if="uploading" class="text-center mb-4"> <div v-if="uploading" class="text-center mb-4">
<font-awesome icon="spinner" class="fa-spin-pulse" /> <font-awesome icon="spinner" class="fa-spin-pulse" />

35
src/views/StartView.vue

@ -3,26 +3,31 @@
id="Content" id="Content"
class="p-6 pb-24 min-h-screen flex flex-col justify-center" class="p-6 pb-24 min-h-screen flex flex-col justify-center"
> >
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<div 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="goBack"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Generate an Identity Generate an Identity
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="goBack()"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- id used by puppeteer test script --> <!-- id used by puppeteer test script -->
<div id="start-question" class="mt-8"> <div id="start-question">
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<p class="text-center text-xl font-light"> <p class="text-center text-xl font-light">
How do you want to create this identifier? How do you want to create this identifier?

33
src/views/StatisticsView.vue

@ -3,22 +3,27 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<!-- Back --> <h1 class="grow text-xl text-center font-semibold leading-tight">
<div 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="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Achievements & Statistics Achievements & Statistics
</h1> </h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div> <div>

31
src/views/TestView.vue

@ -3,22 +3,25 @@
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <!-- Sub View Heading -->
<div class="mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">Test</h1>
<!-- Back --> <!-- Back -->
<div class="text-lg text-center font-light relative px-7"> <a
<h1 class="order-first text-lg text-center leading-none p-1"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" @click="$router.go(-1)"
@click="$router.back()" >
> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome> </a>
</h1>
</div>
<!-- Heading --> <!-- Help button -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8"> <router-link
Test :to="{ name: 'help' }"
</h1> class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<div v-if="isNotProdServer"> <div v-if="isNotProdServer">

33
src/views/UserProfileView.vue

@ -1,22 +1,31 @@
<template> <template>
<QuickNav selected="Discover" /> <QuickNav selected="Discover" />
<TopMessage />
<!-- CONTENT --> <!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb --> <TopMessage />
<div id="ViewBreadcrumb" class="mb-8">
<h1 id="ViewHeading" class="text-lg text-center font-light relative px-7"> <!-- Sub View Heading -->
<!-- Back --> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<button <h1 class="grow text-xl text-center font-semibold leading-tight">
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</button>
Individual Profile Individual Profile
</h1> </h1>
<div class="text-sm text-center text-slate-500"></div>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div> </div>
<!-- Loading Animation --> <!-- Loading Animation -->

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

@ -100,7 +100,10 @@ test('Create new project, then search for it', async ({ page }) => {
const finalTitle = standardTitle + finalRandomString; const finalTitle = standardTitle + finalRandomString;
const finalDescription = standardDescription + finalRandomString; const finalDescription = standardDescription + finalRandomString;
const editedTitle = finalTitle + standardEdit; const editedTitle = finalTitle + standardEdit;
const editedDescription = finalDescription + standardEdit; const editedDescription =
finalDescription +
standardEdit +
" ... and enough text to overflow into the 'Read More' section.";
// Import user 00 // Import user 00
await importUser(page, '00'); await importUser(page, '00');

4
test-playwright/60-new-activity.spec.ts

@ -109,7 +109,7 @@ test('New offers for another user', async ({ page }) => {
await expect(page.getByText('New Offers To You', { exact: true })).toBeVisible(); await expect(page.getByText('New Offers To You', { exact: true })).toBeVisible();
await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click(); await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click();
await expect(page.getByText('The offers are marked as viewed')).toBeVisible(); await expect(page.getByText('The offers are marked as read')).toBeVisible();
await page.getByRole('alert').filter({ hasText: 'Info' }).getByRole('button').click(); // dismiss info alert await page.getByRole('alert').filter({ hasText: 'Info' }).getByRole('button').click(); // dismiss info alert
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@ -140,7 +140,7 @@ test('New offers for another user', async ({ page }) => {
await expect(page.getByText('New Offer To You', { exact: true })).toBeVisible(); await expect(page.getByText('New Offer To You', { exact: true })).toBeVisible();
await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click(); await page.getByTestId('showOffersToUser').locator('div > svg.fa-chevron-right').click();
await expect(page.getByText('The offers are marked as viewed')).toBeVisible(); await expect(page.getByText('The offers are marked as read')).toBeVisible();
await page.getByRole('alert').filter({ hasText: 'Info' }).getByRole('button').click(); // dismiss info alert await page.getByRole('alert').filter({ hasText: 'Info' }).getByRole('button').click(); // dismiss info alert
// now see that no offers are shown as new // now see that no offers are shown as new

5
test-playwright/testUtils.ts

@ -145,9 +145,10 @@ export async function generateNewEthrUser(page: Page): Promise<string> {
} }
// Function to generate a random string of specified length // Function to generate a random string of specified length
// Note that this only generates up to 10 characters
export async function generateRandomString(length: number): Promise<string> { export async function generateRandomString(length: number): Promise<string> {
return Math.random() return Math.random()
.toString(36) .toString(36) // base 36 only generates up to 10 characters
.substring(2, 2 + length); .substring(2, 2 + length);
} }
@ -156,7 +157,7 @@ export async function createUniqueStringsArray(
count: number count: number
): Promise<string[]> { ): Promise<string[]> {
const stringsArray: string[] = []; const stringsArray: string[] = [];
const stringLength = 16; const stringLength = 5; // max of 10; see generateRandomString
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
let randomString = await generateRandomString(stringLength); let randomString = await generateRandomString(stringLength);

Loading…
Cancel
Save