Browse Source

Merge branch 'master' into entity-selection-list-component

pull/216/head
Jose Olarte III 16 hours ago
parent
commit
38f301f053
  1. 2
      .cursor/rules/development/development_guide.mdc
  2. 34
      .husky/pre-commit
  3. 8
      BUILDING.md
  4. 9
      CHANGELOG.md
  5. 4
      android/app/build.gradle
  6. 8
      ios/App/App.xcodeproj/project.pbxproj
  7. 4
      package-lock.json
  8. 2
      package.json
  9. 2
      src/assets/styles/tailwind.css
  10. 506
      src/components/BulkMembersDialog.vue
  11. 6
      src/components/FeedFilters.vue
  12. 438
      src/components/MembersList.vue
  13. 333
      src/components/SetBulkVisibilityDialog.vue
  14. 8
      src/constants/notifications.ts
  15. 9
      src/interfaces/common.ts
  16. 1
      src/interfaces/index.ts
  17. 9
      src/interfaces/user.ts
  18. 44
      src/libs/endorserServer.ts
  19. 6
      src/libs/fontawesome.ts
  20. 89
      src/libs/util.ts
  21. 297
      src/services/platforms/BaseDatabaseService.ts
  22. 86
      src/services/platforms/CapacitorPlatformService.ts
  23. 112
      src/services/platforms/WebPlatformService.ts
  24. 7
      src/views/AccountViewView.vue
  25. 4
      src/views/ContactEditView.vue
  26. 8
      src/views/ContactsView.vue
  27. 14
      src/views/DIDView.vue
  28. 2
      src/views/HomeView.vue
  29. 2
      src/views/OnboardMeetingListView.vue
  30. 8
      src/views/OnboardMeetingSetupView.vue
  31. 84
      test-playwright/testUtils.ts

2
.cursor/rules/development/development_guide.mdc

@ -2,7 +2,7 @@
globs: **/src/**/* globs: **/src/**/*
alwaysApply: false alwaysApply: false
--- ---
✅ use system date command to timestamp all interactions with accurate date and ✅ use system date command to timestamp all documentation with accurate date and
time time
✅ remove whitespace at the end of lines ✅ remove whitespace at the end of lines
✅ use npm run lint-fix to check for warnings ✅ use npm run lint-fix to check for warnings

34
.husky/pre-commit

@ -9,6 +9,10 @@ echo "🔍 Running pre-commit hooks..."
# Run lint-fix first # Run lint-fix first
echo "📝 Running lint-fix..." echo "📝 Running lint-fix..."
# Capture git status before lint-fix to detect changes
git_status_before=$(git status --porcelain)
npm run lint-fix || { npm run lint-fix || {
echo echo
echo "❌ Linting failed. Please fix the issues and try again." echo "❌ Linting failed. Please fix the issues and try again."
@ -18,6 +22,36 @@ npm run lint-fix || {
exit 1 exit 1
} }
# Check if lint-fix made any changes
git_status_after=$(git status --porcelain)
if [ "$git_status_before" != "$git_status_after" ]; then
echo
echo "⚠️ lint-fix made changes to your files!"
echo "📋 Changes detected:"
git diff --name-only
echo
echo "❓ What would you like to do?"
echo " [c] Continue commit without the new changes"
echo " [a] Abort commit (recommended - review and stage the changes)"
echo
printf "Choose [c/a]: "
# The `< /dev/tty` is necessary to make read work in git's non-interactive shell
read choice < /dev/tty
case $choice in
[Cc]* )
echo "✅ Continuing commit without lint-fix changes..."
sleep 3
;;
[Aa]* | * )
echo "🛑 Commit aborted. Please review the changes made by lint-fix."
echo "💡 You can stage the changes with 'git add .' and commit again."
exit 1
;;
esac
fi
# Then run Build Architecture Guard # Then run Build Architecture Guard
#echo "🏗️ Running Build Architecture Guard..." #echo "🏗️ Running Build Architecture Guard..."

8
BUILDING.md

@ -1158,10 +1158,10 @@ If you need to build manually or want to understand the individual steps:
export GEM_PATH=$shortened_path export GEM_PATH=$shortened_path
``` ```
##### 1. Bump the version in package.json, then here ##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
```bash ```bash
cd ios/App && xcrun agvtool new-version 40 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.7;/g" App.xcodeproj/project.pbxproj && cd - cd ios/App && xcrun agvtool new-version 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
``` ```
@ -1318,8 +1318,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then here: android/app/build.gradle ##### 1. Bump the version in package.json, then here: android/app/build.gradle
```bash ```bash
perl -p -i -e 's/versionCode .*/versionCode 40/g' android/app/build.gradle perl -p -i -e 's/versionCode .*/versionCode 46/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.0.7"/g' android/app/build.gradle perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle
``` ```
##### 2. Build ##### 2. Build

9
CHANGELOG.md

@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.1] - 2025.11.03
### Added
- Meeting onboarding via prompts
- Emojis on gift feed
- Starred projects with notification
## [1.0.7] - 2025.08.18 ## [1.0.7] - 2025.08.18
### Fixed ### Fixed

4
android/app/build.gradle

@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 41 versionCode 46
versionName "1.0.8" versionName "1.1.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

8
ios/App/App.xcodeproj/project.pbxproj

@ -403,7 +403,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41; CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -413,7 +413,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.8; MARKETING_VERSION = 1.1.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -430,7 +430,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41; CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -440,7 +440,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0.8; MARKETING_VERSION = 1.1.1;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.1-beta", "version": "1.1.2-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "timesafari", "name": "timesafari",
"version": "1.1.1-beta", "version": "1.1.2-beta",
"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",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.1-beta", "version": "1.1.2-beta",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"

2
src/assets/styles/tailwind.css

@ -38,7 +38,7 @@
} }
.dialog { .dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto; @apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[90%] overflow-y-auto;
} }
/* Markdown content styling to restore list elements */ /* Markdown content styling to restore list elements */

506
src/components/BulkMembersDialog.vue

@ -0,0 +1,506 @@
<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">
{{ title }}
</h3>
<p class="text-sm mb-4">
{{ description }}
</p>
<!-- Member Selection Table -->
<div class="mb-4">
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<!-- Select All Header -->
<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>
<!-- Empty State -->
<tr v-if="!membersData || membersData.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
{{ emptyStateText }}
</td>
</tr>
<!-- Member Rows -->
<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)"
/>
<div class="">
<div class="text-sm font-semibold">
{{ member.name || SOMEONE_UNNAMED }}
</div>
<div
class="flex items-center gap-0.5 text-xs text-slate-500"
>
<span class="font-semibold sm:hidden">DID:</span>
<span
class="w-[35vw] sm:w-auto truncate text-left"
style="direction: rtl"
>{{ member.did }}</span
>
</div>
</div>
</label>
<!-- Contact 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>
<!-- Select All Footer -->
<tfoot 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>
</tfoot>
</table>
</div>
<!-- Action Buttons -->
<div class="space-y-2">
<!-- Main Action Button -->
<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="handleMainAction"
>
{{ buttonText }}
</button>
<!-- Cancel 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"
>
Maybe Later
</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 { MemberData } from "@/interfaces";
import { setVisibilityUtil, getHeaders, register } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
import { Contact } from "@/db/tables/contacts";
@Component({
mixins: [PlatformServiceMixin],
emits: ["close"],
})
export default class BulkMembersDialog extends Vue {
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
@Prop({ required: true }) dialogType!: "admit" | "visibility";
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
// Component state
membersData: MemberData[] = [];
selectedMembers: string[] = [];
visible = 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 title() {
return this.isOrganizer
? "Admit Pending Members"
: "Add Members to Contacts";
}
get description() {
return this.isOrganizer
? "Would you like to admit these members to the meeting and add them to your contacts?"
: "Would you like to add these members to your contacts?";
}
get buttonText() {
return this.isOrganizer ? "Admit + Add to Contacts" : "Add to Contacts";
}
get emptyStateText() {
return this.isOrganizer
? "No pending members to admit"
: "No members are not in your contacts";
}
created() {
this.notify = createNotifyHelpers(this.$notify);
}
open(members: MemberData[]) {
this.visible = true;
this.membersData = members;
// Select all by default
this.selectedMembers = this.membersData.map((member) => member.did);
}
close(notSelectedMemberDids: string[]) {
this.visible = false;
this.$emit("close", { notSelectedMemberDids: notSelectedMemberDids });
}
cancel() {
this.close(this.membersData.map((member) => member.did));
}
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 handleMainAction() {
if (this.dialogType === "admit") {
await this.organizerAdmitAndAddWithVisibility();
} else {
await this.memberAddContactWithVisibility();
}
}
async organizerAdmitAndAddWithVisibility() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers: MemberData[] = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let admittedCount = 0;
let contactAddedCount = 0;
let errors = 0;
for (const member of selectedMembers) {
try {
// First, admit the member
await this.admitMember(member);
// Register them
await this.registerMember(member);
admittedCount++;
// If they're not a contact yet, add them as a contact
if (!member.isContact) {
await this.addAsContact(member, true);
contactAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
errors++;
}
}
// Show success notification
if (admittedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
},
10000,
);
}
if (errors > 0) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to fully admit some members. Work with them individually below.",
},
5000,
);
}
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Some errors occurred. Work with members individually below.",
},
5000,
);
}
}
async memberAddContactWithVisibility() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers: MemberData[] = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let contactsAddedCount = 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, undefined);
contactsAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} 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: "Contacts Added Successfully",
text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error adding contacts:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Some errors occurred. Work with members individually below.",
},
5000,
);
}
}
async admitMember(member: {
did: string;
name: string;
member: { memberId: string };
}) {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`,
{ admitted: true },
{ headers },
);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error admitting member:", err);
throw err;
}
}
async registerMember(member: MemberData) {
try {
const contact: Contact = { did: member.did };
const result = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (result.success) {
if (result.embeddedRecordError) {
throw new Error(result.embeddedRecordError);
}
await this.$updateContact(member.did, { registered: true });
} else {
throw result;
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error registering member:", err);
throw err;
}
}
async addAsContact(
member: { did: string; name: string },
isRegistered?: boolean,
) {
try {
const newContact: Contact = {
did: member.did,
name: member.name,
registered: isRegistered,
};
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() {
const message =
this.dialogType === "admit"
? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet.";
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Info",
text: message,
},
5000,
);
}
}
</script>

6
src/components/FeedFilters.vue

@ -211,8 +211,6 @@ export default class FeedFilters extends Vue {
} }
</script> </script>
<style> <style scoped>
#dialogFeedFilters.dialog-overlay { /* Component-specific styles if needed */
overflow: scroll;
}
</style> </style>

438
src/components/MembersList.vue

@ -1,4 +1,5 @@
<template> <template>
<div>
<div class="space-y-4"> <div class="space-y-4">
<!-- Loading State --> <!-- Loading State -->
<div <div
@ -19,35 +20,29 @@
You are not currently admitted by the organizer. You are not currently admitted by the organizer.
</div> </div>
<div v-if="!firstName" class="py-4 text-red-600"> <div v-if="!firstName" class="py-4 text-red-600">
Your name is not set, so others may not recognize you. Reload this page Your name is not set, so others may not recognize you. Reload this
to set it. page to set it.
</div> </div>
<ul class="list-disc text-sm ps-4 space-y-2 mb-4"> <ul class="list-disc text-sm ps-4 space-y-2 mb-4">
<li <li
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer" v-if="
membersToShow().length > 0 && showOrganizerTools && isOrganizer
"
> >
Click Click
<span <font-awesome icon="circle-plus" class="text-blue-500 text-sm" />
class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
>
<font-awesome icon="plus" class="text-sm" />
</span>
/ /
<span <font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
class="inline-block w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-center"
>
<font-awesome icon="minus" class="text-sm" />
</span>
to add/remove them to/from the meeting. to add/remove them to/from the meeting.
</li> </li>
<li v-if="membersToShow().length > 0"> <li
Click v-if="
<span membersToShow().length > 0 && getNonContactMembers().length > 0
class="inline-block w-5 h-5 rounded-full bg-green-100 text-green-600 text-center" "
> >
<font-awesome icon="circle-user" class="text-sm" /> Click
</span> <font-awesome icon="circle-user" class="text-green-600 text-sm" />
to add them to your contacts. to add them to your contacts.
</li> </li>
</ul> </ul>
@ -60,7 +55,7 @@
<button <button
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" 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 now" title="Refresh members list now"
@click="manualRefresh" @click="refreshData(false)"
> >
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" /> <font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh Refresh
@ -74,19 +69,54 @@
<li <li
v-for="member in membersToShow()" v-for="member in membersToShow()"
:key="member.member.memberId" :key="member.member.memberId"
class="border-b border-slate-300 py-1.5" :class="[
'border-b px-2 sm:px-3 py-1.5',
{
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
!member.member.admitted &&
(isOrganizer || member.did === activeDid),
},
{ 'border-slate-300': member.member.admitted },
]"
> >
<div class="flex items-center gap-2 justify-between"> <div class="flex items-center gap-2 justify-between">
<div class="flex items-center gap-1 overflow-hidden"> <div class="flex items-center gap-1 overflow-hidden">
<h3 class="font-semibold truncate"> <h3
:class="[
'font-semibold truncate',
{
'text-slate-500':
!member.member.admitted &&
(isOrganizer || member.did === activeDid),
},
]"
>
<font-awesome
v-if="member.member.memberId === members[0]?.memberId"
icon="crown"
class="fa-fw text-amber-400"
/>
<font-awesome
v-if="member.did === activeDid"
icon="hand"
class="fa-fw text-slate-500"
/>
<font-awesome
v-if="
!member.member.admitted &&
(isOrganizer || member.did === activeDid)
"
icon="hourglass-half"
class="fa-fw text-slate-400"
/>
{{ member.name || unnamedMember }} {{ member.name || unnamedMember }}
</h3> </h3>
<div <div
v-if="!getContactFor(member.did) && member.did !== activeDid" v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1" class="flex items-center gap-1.5 ml-2 ms-1"
> >
<button <button
class="btn-add-contact" class="btn-add-contact ml-2"
title="Add as contact" title="Add as contact"
@click="addAsContact(member)" @click="addAsContact(member)"
> >
@ -94,7 +124,7 @@
</button> </button>
<button <button
class="btn-info-contact" class="btn-info-contact ml-2"
title="Contact Info" title="Contact Info"
@click=" @click="
informAboutAddingContact( informAboutAddingContact(
@ -102,25 +132,52 @@
) )
" "
> >
<font-awesome icon="circle-info" class="text-sm" /> <font-awesome icon="circle-info" />
</button> </button>
</div> </div>
<div
v-if="getContactFor(member.did) && member.did !== activeDid"
class="flex items-center gap-1.5 ms-1"
>
<router-link
:to="{ name: 'contact-edit', params: { did: member.did } }"
>
<font-awesome
icon="pen"
class="text-sm text-blue-500 ml-2 mb-1"
/>
</router-link>
<router-link
:to="{ name: 'did', params: { did: member.did } }"
>
<font-awesome
icon="arrow-up-right-from-square"
class="text-sm text-blue-500 ml-2 mb-1"
/>
</router-link>
</div>
</div> </div>
<span <span
v-if=" v-if="
showOrganizerTools && isOrganizer && member.did !== activeDid showOrganizerTools && isOrganizer && member.did !== activeDid
" "
class="flex items-center gap-1" class="flex items-center gap-1.5"
> >
<button <button
class="btn-admission" :class="
member.member.admitted
? 'btn-admission-remove'
: 'btn-admission-add'
"
:title=" :title="
member.member.admitted ? 'Remove member' : 'Admit member' member.member.admitted ? 'Remove member' : 'Admit member'
" "
@click="checkWhetherContactBeforeAdmitting(member)" @click="checkWhetherContactBeforeAdmitting(member)"
> >
<font-awesome <font-awesome
:icon="member.member.admitted ? 'minus' : 'plus'" :icon="
member.member.admitted ? 'circle-minus' : 'circle-plus'
"
/> />
</button> </button>
@ -129,7 +186,7 @@
title="Admission Info" title="Admission Info"
@click="informAboutAdmission()" @click="informAboutAdmission()"
> >
<font-awesome icon="circle-info" class="text-sm" /> <font-awesome icon="circle-info" />
</button> </button>
</span> </span>
</div> </div>
@ -147,7 +204,7 @@
<button <button
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" 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 now" title="Refresh members list now"
@click="manualRefresh" @click="refreshData(false)"
> >
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" /> <font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
Refresh Refresh
@ -161,37 +218,39 @@
</div> </div>
</div> </div>
<!-- Set Visibility Dialog Component --> <!-- Bulk Members Dialog for both admitting and setting visibility -->
<SetBulkVisibilityDialog <BulkMembersDialog
:visible="showSetVisibilityDialog" ref="bulkMembersDialog"
:members-data="visibilityDialogMembers"
:active-did="activeDid" :active-did="activeDid"
:api-server="apiServer" :api-server="apiServer"
@close="closeSetVisibilityDialog" :dialog-type="isOrganizer ? 'admit' : 'visibility'"
:is-organizer="isOrganizer"
@close="closeBulkMembersDialogCallback"
/> />
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator"; import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import {
NOTIFY_ADD_CONTACT_FIRST,
NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { import {
errorStringForLog, errorStringForLog,
getHeaders, getHeaders,
register, register,
serverMessageForUser, serverMessageForUser,
} from "../libs/endorserServer"; } from "@/libs/endorserServer";
import { decryptMessage } from "../libs/crypto"; import { decryptMessage } from "@/libs/crypto";
import { Contact } from "../db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "../libs/util"; import { MemberData } from "@/interfaces";
import { NotificationIface } from "../constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { import BulkMembersDialog from "./BulkMembersDialog.vue";
NOTIFY_ADD_CONTACT_FIRST,
NOTIFY_CONTINUE_WITHOUT_ADDING,
} from "@/constants/notifications";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import SetBulkVisibilityDialog from "./SetBulkVisibilityDialog.vue";
interface Member { interface Member {
admitted: boolean; admitted: boolean;
@ -208,7 +267,7 @@ interface DecryptedMember {
@Component({ @Component({
components: { components: {
SetBulkVisibilityDialog, BulkMembersDialog,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@ -216,7 +275,6 @@ export default class MembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
notify!: ReturnType<typeof createNotifyHelpers>; notify!: ReturnType<typeof createNotifyHelpers>;
libsUtil = libsUtil;
@Prop({ required: true }) password!: string; @Prop({ required: true }) password!: string;
@Prop({ default: false }) showOrganizerTools!: boolean; @Prop({ default: false }) showOrganizerTools!: boolean;
@ -227,6 +285,7 @@ export default class MembersList extends Vue {
return message; return message;
} }
contacts: Array<Contact> = [];
decryptedMembers: DecryptedMember[] = []; decryptedMembers: DecryptedMember[] = [];
firstName = ""; firstName = "";
isLoading = true; isLoading = true;
@ -237,23 +296,11 @@ export default class MembersList extends Vue {
activeDid = ""; activeDid = "";
apiServer = ""; apiServer = "";
// Set Visibility Dialog state
showSetVisibilityDialog = false;
visibilityDialogMembers: Array<{
did: string;
name: string;
isContact: boolean;
member: { memberId: string };
}> = [];
contacts: Array<Contact> = [];
// Auto-refresh functionality // Auto-refresh functionality
countdownTimer = 10; countdownTimer = 10;
autoRefreshInterval: NodeJS.Timeout | null = null; autoRefreshInterval: NodeJS.Timeout | null = null;
lastRefreshTime = 0; lastRefreshTime = 0;
previousMemberDidsIgnored: string[] = [];
// Track previous visibility members to detect changes
previousVisibilityMembers: string[] = [];
/** /**
* Get the unnamed member constant * Get the unnamed member constant
@ -274,23 +321,8 @@ export default class MembersList extends Vue {
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || ""; this.firstName = settings.firstName || "";
await this.fetchMembers();
await this.loadContacts();
// Start auto-refresh this.refreshData();
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() {
@ -336,7 +368,10 @@ export default class MembersList extends Vue {
const content = JSON.parse(decryptedContent); const content = JSON.parse(decryptedContent);
this.decryptedMembers.push({ this.decryptedMembers.push({
member: member, member: {
...member,
admitted: member.admitted !== undefined ? member.admitted : true, // Default to true for non-organizers
},
name: content.name, name: content.name,
did: content.did, did: content.did,
isRegistered: !!content.isRegistered, isRegistered: !!content.isRegistered,
@ -378,17 +413,76 @@ export default class MembersList extends Vue {
} }
membersToShow(): DecryptedMember[] { membersToShow(): DecryptedMember[] {
let members: DecryptedMember[] = [];
if (this.isOrganizer) { if (this.isOrganizer) {
if (this.showOrganizerTools) { if (this.showOrganizerTools) {
return this.decryptedMembers; members = this.decryptedMembers;
} else { } else {
return this.decryptedMembers.filter( members = this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted, (member: DecryptedMember) => member.member.admitted,
); );
} }
} else {
// non-organizers only get visible members from server, plus themselves
// Check if current user is already in the decrypted members list
if (
!this.decryptedMembers.find((member) => member.did === this.activeDid)
) {
// this is a stub for this user just in case they are waiting to get in
// which is especially useful so they can see their own DID
const currentUser: DecryptedMember = {
member: {
admitted: false,
content: "{}",
memberId: -1,
},
name: this.firstName,
did: this.activeDid,
isRegistered: false,
};
members = [currentUser, ...this.decryptedMembers];
} else {
members = this.decryptedMembers;
} }
// non-organizers only get visible members from server }
return this.decryptedMembers;
// Sort members according to priority:
// 1. Organizer at the top
// 2. Current user next
// 3. Non-admitted members next
// 4. Everyone else after
return members.sort((a, b) => {
// Check if either member is the organizer (first member in original list)
const aIsOrganizer = a.member.memberId === this.members[0]?.memberId;
const bIsOrganizer = b.member.memberId === this.members[0]?.memberId;
// Check if either member is the current user
const aIsCurrentUser = a.did === this.activeDid;
const bIsCurrentUser = b.did === this.activeDid;
// Organizer always comes first
if (aIsOrganizer && !bIsOrganizer) return -1;
if (!aIsOrganizer && bIsOrganizer) return 1;
// If both are organizers, maintain original order
if (aIsOrganizer && bIsOrganizer) return 0;
// Current user comes second (after organizer)
if (aIsCurrentUser && !bIsCurrentUser && !bIsOrganizer) return -1;
if (!aIsCurrentUser && bIsCurrentUser && !aIsOrganizer) return 1;
// If both are current users, maintain original order
if (aIsCurrentUser && bIsCurrentUser) return 0;
// Non-admitted members come before admitted members
if (!a.member.admitted && b.member.admitted) return -1;
if (a.member.admitted && !b.member.admitted) return 1;
// If admission status is the same, maintain original order
return 0;
});
} }
informAboutAdmission() { informAboutAdmission() {
@ -412,92 +506,85 @@ export default class MembersList extends Vue {
} }
} }
async loadContacts() {
this.contacts = await this.$getAllContacts();
}
getContactFor(did: string): Contact | undefined { getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did); return this.contacts.find((contact) => contact.did === did);
} }
getMembersForVisibility() { getPendingMembersToAdmit(): MemberData[] {
return this.decryptedMembers return this.decryptedMembers
.filter((member) => { .filter(
// Exclude the current user (member) => member.did !== this.activeDid && !member.member.admitted,
if (member.did === this.activeDid) { )
return false; .map(this.convertDecryptedMemberToMemberData);
} }
const contact = this.getContactFor(member.did); getNonContactMembers(): MemberData[] {
return this.decryptedMembers
.filter(
(member) =>
member.did !== this.activeDid && !this.getContactFor(member.did),
)
.map(this.convertDecryptedMemberToMemberData);
}
// Include members who: convertDecryptedMemberToMemberData(
// 1. Haven't been added as contacts yet, OR decryptedMember: DecryptedMember,
// 2. Are contacts but don't have visibility set (seesMe property) ): MemberData {
return !contact || !contact.seesMe; return {
}) did: decryptedMember.did,
.map((member) => ({ name: decryptedMember.name,
did: member.did, isContact: !!this.getContactFor(decryptedMember.did),
name: member.name,
isContact: !!this.getContactFor(member.did),
member: { member: {
memberId: member.member.memberId.toString(), memberId: decryptedMember.member.memberId.toString(),
}, },
})); };
} }
/** /**
* Check if we should show the visibility dialog * Show the bulk members dialog if conditions are met
* Returns true if there are members for visibility and either: * (admit pending members for organizers, add to contacts for non-organizers)
* - This is the first time (no previous members tracked), OR
* - New members have been added since last check (not removed)
*/ */
shouldShowVisibilityDialog(): boolean { async refreshData(bypassPromptIfAllWereIgnored = true) {
const currentMembers = this.getMembersForVisibility(); // Force refresh both contacts and members
this.contacts = await this.$getAllContacts();
if (currentMembers.length === 0) { await this.fetchMembers();
return false;
}
// If no previous members tracked, show dialog const pendingMembers = this.isOrganizer
if (this.previousVisibilityMembers.length === 0) { ? this.getPendingMembersToAdmit()
return true; : this.getNonContactMembers();
if (pendingMembers.length === 0) {
this.startAutoRefresh();
return;
} }
if (bypassPromptIfAllWereIgnored) {
// Check if new members have been added (not just any change) // only show if there are members that have not been ignored
const currentMemberIds = currentMembers.map((m) => m.did); const pendingMembersNotIgnored = pendingMembers.filter(
const previousMemberIds = this.previousVisibilityMembers; (member) => !this.previousMemberDidsIgnored.includes(member.did),
// Find new members (members in current but not in previous)
const newMembers = currentMemberIds.filter(
(id) => !previousMemberIds.includes(id),
); );
if (pendingMembersNotIgnored.length === 0) {
// Only show dialog if there are new members added this.startAutoRefresh();
return newMembers.length > 0; // everyone waiting has been ignored
return;
} }
/**
* Update the tracking of previous visibility members
*/
updatePreviousVisibilityMembers() {
const currentMembers = this.getMembersForVisibility();
this.previousVisibilityMembers = currentMembers.map((m) => m.did);
} }
this.stopAutoRefresh();
/** (this.$refs.bulkMembersDialog as BulkMembersDialog).open(pendingMembers);
* Show the visibility dialog if conditions are met
*/
checkAndShowVisibilityDialog() {
if (this.shouldShowVisibilityDialog()) {
this.showSetBulkVisibilityDialog();
} }
this.updatePreviousVisibilityMembers();
// Bulk Members Dialog methods
async closeBulkMembersDialogCallback(
result: { notSelectedMemberDids: string[] } | undefined,
) {
this.previousMemberDidsIgnored = result?.notSelectedMemberDids || [];
await this.refreshData();
} }
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) {
// If not a contact, show confirmation dialog // If not a contact, stop auto-refresh and show confirmation dialog
this.stopAutoRefresh();
this.$notify( this.$notify(
{ {
group: "modal", group: "modal",
@ -510,6 +597,7 @@ export default class MembersList extends Vue {
await this.addAsContact(decrMember); await this.addAsContact(decrMember);
// After adding as contact, proceed with admission // After adding as contact, proceed with admission
await this.toggleAdmission(decrMember); await this.toggleAdmission(decrMember);
this.startAutoRefresh();
}, },
onNo: async () => { onNo: async () => {
// If they choose not to add as contact, show second confirmation // If they choose not to add as contact, show second confirmation
@ -522,14 +610,19 @@ export default class MembersList extends Vue {
yesText: NOTIFY_CONTINUE_WITHOUT_ADDING.yesText, yesText: NOTIFY_CONTINUE_WITHOUT_ADDING.yesText,
onYes: async () => { onYes: async () => {
await this.toggleAdmission(decrMember); await this.toggleAdmission(decrMember);
this.startAutoRefresh();
}, },
onCancel: async () => { onCancel: async () => {
// Do nothing, effectively canceling the operation // Do nothing, effectively canceling the operation
this.startAutoRefresh();
}, },
}, },
TIMEOUTS.MODAL, TIMEOUTS.MODAL,
); );
}, },
onCancel: async () => {
this.startAutoRefresh();
},
}, },
TIMEOUTS.MODAL, TIMEOUTS.MODAL,
); );
@ -632,19 +725,8 @@ export default class MembersList extends Vue {
} }
} }
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() { startAutoRefresh() {
this.stopAutoRefresh();
this.lastRefreshTime = Date.now(); this.lastRefreshTime = Date.now();
this.countdownTimer = 10; this.countdownTimer = 10;
@ -674,33 +756,6 @@ export default class MembersList extends Vue {
} }
} }
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() { beforeDestroy() {
this.stopAutoRefresh(); this.stopAutoRefresh();
} }
@ -718,23 +773,26 @@ 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 w-6 h-6 flex items-center justify-center rounded-full @apply text-lg text-green-600 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 { .btn-info-admission {
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@apply w-6 h-6 flex items-center justify-center rounded-full @apply text-slate-400 hover:text-slate-600
bg-slate-100 text-slate-400 hover:text-slate-600
transition-colors; transition-colors;
} }
.btn-admission { .btn-admission-add {
/* stylelint-disable-next-line at-rule-no-unknown */ /* stylelint-disable-next-line at-rule-no-unknown */
@apply w-6 h-6 flex items-center justify-center rounded-full @apply text-lg text-blue-500 hover:text-blue-700
bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors;
}
.btn-admission-remove {
/* stylelint-disable-next-line at-rule-no-unknown */
@apply text-lg text-rose-500 hover:text-rose-700
transition-colors; transition-colors;
} }
</style> </style>

333
src/components/SetBulkVisibilityDialog.vue

@ -1,333 +0,0 @@
<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>

8
src/constants/notifications.ts

@ -510,14 +510,6 @@ export const NOTIFY_REGISTER_CONTACT = {
text: "Do you want to register them?", text: "Do you want to register them?",
}; };
// Used in: ContactsView.vue (showOnboardMeetingDialog method - complex modal for onboarding meeting)
export const NOTIFY_ONBOARDING_MEETING = {
title: "Onboarding Meeting",
text: "Would you like to start a new meeting?",
yesText: "Start New Meeting",
noText: "Join Existing Meeting",
};
// TestView.vue specific constants // TestView.vue specific constants
// Used in: TestView.vue (executeSql method - SQL error handling) // Used in: TestView.vue (executeSql method - SQL error handling)
export const NOTIFY_SQL_ERROR = { export const NOTIFY_SQL_ERROR = {

9
src/interfaces/common.ts

@ -70,15 +70,6 @@ export interface AxiosErrorResponse {
[key: string]: unknown; [key: string]: unknown;
} }
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
export interface CreateAndSubmitClaimResult { export interface CreateAndSubmitClaimResult {
success: boolean; success: boolean;
embeddedRecordError?: string; embeddedRecordError?: string;

1
src/interfaces/index.ts

@ -4,3 +4,4 @@ export * from "./common";
export * from "./deepLinks"; export * from "./deepLinks";
export * from "./limits"; export * from "./limits";
export * from "./records"; export * from "./records";
export * from "./user";

9
src/interfaces/user.ts

@ -6,3 +6,12 @@ export interface UserInfo {
profileImageUrl?: string; profileImageUrl?: string;
nextPublicEncKeyHash?: string; nextPublicEncKeyHash?: string;
} }
export interface MemberData {
did: string;
name: string;
isContact: boolean;
member: {
memberId: string;
};
}

44
src/libs/endorserServer.ts

@ -42,9 +42,6 @@ import {
PlanActionClaim, PlanActionClaim,
RegisterActionClaim, RegisterActionClaim,
TenureClaim, TenureClaim,
} from "../interfaces/claims";
import {
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
AxiosErrorResponse, AxiosErrorResponse,
@ -55,14 +52,12 @@ import {
QuantitativeValue, QuantitativeValue,
KeyMetaWithPrivate, KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate, KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import {
OfferSummaryRecord, OfferSummaryRecord,
OfferToPlanSummaryRecord, OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim, PlanSummaryAndPreviousClaim,
PlanSummaryRecord, PlanSummaryRecord,
} from "../interfaces/records"; } from "../interfaces";
import { logger } from "../utils/logger"; import { logger, safeStringify } 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";
@ -702,7 +697,7 @@ export function serverMessageForUser(error: unknown): string | undefined {
export function errorStringForLog(error: unknown) { export function errorStringForLog(error: unknown) {
let stringifiedError = "" + error; let stringifiedError = "" + error;
try { try {
stringifiedError = JSON.stringify(error); stringifiedError = safeStringify(error);
} catch (e) { } catch (e) {
// can happen with Dexie, eg: // can happen with Dexie, eg:
// TypeError: Converting circular structure to JSON // TypeError: Converting circular structure to JSON
@ -714,7 +709,7 @@ export function errorStringForLog(error: unknown) {
if (error && typeof error === "object" && "response" in error) { if (error && typeof error === "object" && "response" in error) {
const err = error as AxiosErrorResponse; const err = error as AxiosErrorResponse;
const errorResponseText = JSON.stringify(err.response); const errorResponseText = safeStringify(err.response);
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions) // for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) { if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
// add error.response stuff // add error.response stuff
@ -724,7 +719,7 @@ export function errorStringForLog(error: unknown) {
R.equals(err.config, err.response.config) R.equals(err.config, err.response.config)
) { ) {
// but exclude "config" because it's already in there // but exclude "config" because it's already in there
const newErrorResponseText = JSON.stringify( const newErrorResponseText = safeStringify(
R.omit(["config"] as never[], err.response), R.omit(["config"] as never[], err.response),
); );
fullError += fullError +=
@ -1662,30 +1657,35 @@ export async function register(
message?: string; message?: string;
}>(url, { jwtEncoded: vcJwt }); }>(url, { jwtEncoded: vcJwt });
if (resp.data?.success?.handleId) { if (resp.data?.success?.embeddedRecordError) {
return { success: true };
} else if (resp.data?.success?.embeddedRecordError) {
let message = let message =
"There was some problem with the registration and so it may not be complete."; "There was some problem with the registration and so it may not be complete.";
if (typeof resp.data.success.embeddedRecordError === "string") { if (typeof resp.data.success.embeddedRecordError === "string") {
message += " " + resp.data.success.embeddedRecordError; message += " " + resp.data.success.embeddedRecordError;
} }
return { error: message }; return { error: message };
} else if (resp.data?.success?.handleId) {
return { success: true };
} else { } else {
logger.error("Registration error:", JSON.stringify(resp.data)); logger.error("Registration non-thrown error:", JSON.stringify(resp.data));
return { error: "Got a server error when registering." }; return {
error:
(resp.data?.error as { message?: string })?.message ||
(resp.data?.error as string) ||
"Got a server error when registering.",
};
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error && typeof error === "object") { if (error && typeof error === "object") {
const err = error as AxiosErrorResponse; const err = error as AxiosErrorResponse;
const errorMessage = const errorMessage =
err.message || err.response?.data?.error?.message ||
(err.response?.data && err.response?.data?.error ||
typeof err.response.data === "object" && err.message;
"message" in err.response.data logger.error(
? (err.response.data as { message: string }).message "Registration thrown error:",
: undefined); errorMessage || JSON.stringify(err),
logger.error("Registration error:", errorMessage || JSON.stringify(err)); );
return { error: errorMessage || "Got a server error when registering." }; return { error: errorMessage || "Got a server error when registering." };
} }
return { error: "Got a server error when registering." }; return { error: "Got a server error when registering." };

6
src/libs/fontawesome.ts

@ -29,6 +29,7 @@ import {
faCircle, faCircle,
faCircleCheck, faCircleCheck,
faCircleInfo, faCircleInfo,
faCircleMinus,
faCirclePlus, faCirclePlus,
faCircleQuestion, faCircleQuestion,
faCircleRight, faCircleRight,
@ -37,6 +38,7 @@ import {
faCoins, faCoins,
faComment, faComment,
faCopy, faCopy,
faCrown,
faDollar, faDollar,
faDownload, faDownload,
faEllipsis, faEllipsis,
@ -58,6 +60,7 @@ import {
faHand, faHand,
faHandHoldingDollar, faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHourglassHalf,
faHouseChimney, faHouseChimney,
faImage, faImage,
faImagePortrait, faImagePortrait,
@ -123,6 +126,7 @@ library.add(
faCircle, faCircle,
faCircleCheck, faCircleCheck,
faCircleInfo, faCircleInfo,
faCircleMinus,
faCirclePlus, faCirclePlus,
faCircleQuestion, faCircleQuestion,
faCircleRight, faCircleRight,
@ -131,6 +135,7 @@ library.add(
faCoins, faCoins,
faComment, faComment,
faCopy, faCopy,
faCrown,
faDollar, faDollar,
faDownload, faDownload,
faEllipsis, faEllipsis,
@ -152,6 +157,7 @@ library.add(
faHand, faHand,
faHandHoldingDollar, faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHourglassHalf,
faHouseChimney, faHouseChimney,
faImage, faImage,
faImagePortrait, faImagePortrait,

89
src/libs/util.ts

@ -988,11 +988,6 @@ export async function importFromMnemonic(
): Promise<void> { ): Promise<void> {
const mne: string = mnemonic.trim().toLowerCase(); const mne: string = mnemonic.trim().toLowerCase();
// Check if this is Test User #0
const TEST_USER_0_MNEMONIC =
"rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage";
const isTestUser0 = mne === TEST_USER_0_MNEMONIC;
// Derive address and keys from mnemonic // Derive address and keys from mnemonic
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath); const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
@ -1007,90 +1002,6 @@ export async function importFromMnemonic(
// Save the new identity // Save the new identity
await saveNewIdentity(newId, mne, derivationPath); await saveNewIdentity(newId, mne, derivationPath);
// Set up Test User #0 specific settings
if (isTestUser0) {
// Set up Test User #0 specific settings with enhanced error handling
const platformService = await getPlatformService();
try {
// First, ensure the DID-specific settings record exists
await platformService.insertNewDidIntoSettings(newId.did);
// Then update with Test User #0 specific settings
await platformService.updateDidSpecificSettings(newId.did, {
firstName: "User Zero",
isRegistered: true,
});
// Verify the settings were saved correctly
const verificationResult = await platformService.dbQuery(
"SELECT firstName, isRegistered FROM settings WHERE accountDid = ?",
[newId.did],
);
if (verificationResult?.values?.length) {
const settings = verificationResult.values[0];
const firstName = settings[0];
const isRegistered = settings[1];
logger.debug(
"[importFromMnemonic] Test User #0 settings verification",
{
did: newId.did,
firstName,
isRegistered,
expectedFirstName: "User Zero",
expectedIsRegistered: true,
},
);
// If settings weren't saved correctly, try individual updates
if (firstName !== "User Zero" || isRegistered !== 1) {
logger.warn(
"[importFromMnemonic] Test User #0 settings not saved correctly, retrying with individual updates",
);
await platformService.dbExec(
"UPDATE settings SET firstName = ? WHERE accountDid = ?",
["User Zero", newId.did],
);
await platformService.dbExec(
"UPDATE settings SET isRegistered = ? WHERE accountDid = ?",
[1, newId.did],
);
// Verify again
const retryResult = await platformService.dbQuery(
"SELECT firstName, isRegistered FROM settings WHERE accountDid = ?",
[newId.did],
);
if (retryResult?.values?.length) {
const retrySettings = retryResult.values[0];
logger.debug(
"[importFromMnemonic] Test User #0 settings after retry",
{
firstName: retrySettings[0],
isRegistered: retrySettings[1],
},
);
}
}
} else {
logger.error(
"[importFromMnemonic] Failed to verify Test User #0 settings - no record found",
);
}
} catch (error) {
logger.error(
"[importFromMnemonic] Error setting up Test User #0 settings:",
error,
);
// Don't throw - allow the import to continue even if settings fail
}
}
} }
/** /**

297
src/services/platforms/BaseDatabaseService.ts

@ -0,0 +1,297 @@
/**
* @fileoverview Base Database Service for Platform Services
* @author Matthew Raymer
*
* This abstract base class provides common database operations that are
* identical across all platform implementations. It eliminates code
* duplication and ensures consistency in database operations.
*
* Key Features:
* - Common database utility methods
* - Consistent settings management
* - Active identity management
* - Abstract methods for platform-specific database operations
*
* Architecture:
* - Abstract base class with common implementations
* - Platform services extend this class
* - Platform-specific database operations remain abstract
*
* @since 1.1.1-beta
*/
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
/**
* Abstract base class for platform-specific database services.
*
* This class provides common database operations that are identical
* across all platform implementations (Web, Capacitor, Electron).
* Platform-specific services extend this class and implement the
* abstract database operation methods.
*
* Common Operations:
* - Settings management (update, retrieve, insert)
* - Active identity management
* - Database utility methods
*
* @abstract
* @example
* ```typescript
* export class WebPlatformService extends BaseDatabaseService {
* async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
* // Web-specific implementation
* }
* }
* ```
*/
export abstract class BaseDatabaseService {
/**
* Generate an INSERT statement for a model object.
*
* Creates a parameterized INSERT statement with placeholders for
* all properties in the model object. This ensures safe SQL
* execution and prevents SQL injection.
*
* @param model - Object containing the data to insert
* @param tableName - Name of the target table
* @returns Object containing the SQL statement and parameters
*
* @example
* ```typescript
* const { sql, params } = this.generateInsertStatement(
* { name: 'John', age: 30 },
* 'users'
* );
* // sql: "INSERT INTO users (name, age) VALUES (?, ?)"
* // params: ['John', 30]
* ```
*/
generateInsertStatement(
model: Record<string, unknown>,
tableName: string,
): { sql: string; params: unknown[] } {
const keys = Object.keys(model);
const placeholders = keys.map(() => "?").join(", ");
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`;
const params = keys.map((key) => model[key]);
return { sql, params };
}
/**
* Update default settings for the currently active account.
*
* Retrieves the active DID from the active_identity table and updates
* the corresponding settings record. This ensures settings are always
* updated for the correct account.
*
* @param settings - Object containing the settings to update
* @returns Promise that resolves when settings are updated
*
* @throws {Error} If no active DID is found or database operation fails
*
* @example
* ```typescript
* await this.updateDefaultSettings({
* theme: 'dark',
* notifications: true
* });
* ```
*/
async updateDefaultSettings(
settings: Record<string, unknown>,
): Promise<void> {
// Get current active DID and update that identity's settings
const activeIdentity = await this.getActiveIdentity();
const activeDid = activeIdentity.activeDid;
if (!activeDid) {
logger.warn(
"[BaseDatabaseService] No active DID found, cannot update default settings",
);
return;
}
const keys = Object.keys(settings);
const setClause = keys.map((key) => `${key} = ?`).join(", ");
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
const params = [...keys.map((key) => settings[key]), activeDid];
await this.dbExec(sql, params);
}
/**
* Update the active DID in the active_identity table.
*
* Sets the active DID and updates the lastUpdated timestamp.
* This is used when switching between different accounts/identities.
*
* @param did - The DID to set as active
* @returns Promise that resolves when the update is complete
*
* @example
* ```typescript
* await this.updateActiveDid('did:example:123');
* ```
*/
async updateActiveDid(did: string): Promise<void> {
await this.dbExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[did],
);
}
/**
* Get the currently active DID from the active_identity table.
*
* Retrieves the active DID that represents the currently selected
* account/identity. This is used throughout the application to
* ensure operations are performed on the correct account.
*
* @returns Promise resolving to object containing the active DID
*
* @example
* ```typescript
* const { activeDid } = await this.getActiveIdentity();
* console.log('Current active DID:', activeDid);
* ```
*/
async getActiveIdentity(): Promise<{ activeDid: string }> {
const result = (await this.dbQuery(
"SELECT activeDid FROM active_identity WHERE id = 1",
)) as QueryExecResult;
return {
activeDid: (result?.values?.[0]?.[0] as string) || "",
};
}
/**
* Insert a new DID into the settings table with default values.
*
* Creates a new settings record for a DID with default configuration
* values. Uses INSERT OR REPLACE to handle cases where settings
* already exist for the DID.
*
* @param did - The DID to create settings for
* @returns Promise that resolves when settings are created
*
* @example
* ```typescript
* await this.insertNewDidIntoSettings('did:example:123');
* ```
*/
async insertNewDidIntoSettings(did: string): Promise<void> {
// Import constants dynamically to avoid circular dependencies
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } =
await import("@/constants/app");
// Use INSERT OR REPLACE to handle case where settings already exist for this DID
// This prevents duplicate accountDid entries and ensures data integrity
await this.dbExec(
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)",
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER],
);
}
/**
* Update settings for a specific DID.
*
* Updates settings for a particular DID rather than the active one.
* This is useful for bulk operations or when managing multiple accounts.
*
* @param did - The DID to update settings for
* @param settings - Object containing the settings to update
* @returns Promise that resolves when settings are updated
*
* @example
* ```typescript
* await this.updateDidSpecificSettings('did:example:123', {
* theme: 'light',
* notifications: false
* });
* ```
*/
async updateDidSpecificSettings(
did: string,
settings: Record<string, unknown>,
): Promise<void> {
const keys = Object.keys(settings);
const setClause = keys.map((key) => `${key} = ?`).join(", ");
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
const params = [...keys.map((key) => settings[key]), did];
await this.dbExec(sql, params);
}
/**
* Retrieve settings for the currently active account.
*
* Gets the active DID and retrieves all settings for that account.
* Excludes the 'id' column from the returned settings object.
*
* @returns Promise resolving to settings object or null if no active DID
*
* @example
* ```typescript
* const settings = await this.retrieveSettingsForActiveAccount();
* if (settings) {
* console.log('Theme:', settings.theme);
* console.log('Notifications:', settings.notifications);
* }
* ```
*/
async retrieveSettingsForActiveAccount(): Promise<Record<
string,
unknown
> | null> {
// Get current active DID from active_identity table
const activeIdentity = await this.getActiveIdentity();
const activeDid = activeIdentity.activeDid;
if (!activeDid) {
return null;
}
const result = (await this.dbQuery(
"SELECT * FROM settings WHERE accountDid = ?",
[activeDid],
)) as QueryExecResult;
if (result?.values?.[0]) {
// Convert the row to an object
const row = result.values[0];
const columns = result.columns || [];
const settings: Record<string, unknown> = {};
columns.forEach((column: string, index: number) => {
if (column !== "id") {
// Exclude the id column
settings[column] = row[index];
}
});
return settings;
}
return null;
}
// Abstract methods that must be implemented by platform-specific services
/**
* Execute a database query (SELECT operations).
*
* @abstract
* @param sql - SQL query string
* @param params - Optional parameters for prepared statements
* @returns Promise resolving to query results
*/
abstract dbQuery(sql: string, params?: unknown[]): Promise<unknown>;
/**
* Execute a database statement (INSERT, UPDATE, DELETE operations).
*
* @abstract
* @param sql - SQL statement string
* @param params - Optional parameters for prepared statements
* @returns Promise resolving to execution results
*/
abstract dbExec(sql: string, params?: unknown[]): Promise<unknown>;
}

86
src/services/platforms/CapacitorPlatformService.ts

@ -22,6 +22,7 @@ import {
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { BaseDatabaseService } from "./BaseDatabaseService";
interface QueuedOperation { interface QueuedOperation {
type: "run" | "query" | "rawQuery"; type: "run" | "query" | "rawQuery";
@ -39,7 +40,10 @@ interface QueuedOperation {
* - Platform-specific features * - Platform-specific features
* - SQLite database operations * - SQLite database operations
*/ */
export class CapacitorPlatformService implements PlatformService { export class CapacitorPlatformService
extends BaseDatabaseService
implements PlatformService
{
/** Current camera direction */ /** Current camera direction */
private currentDirection: CameraDirection = CameraDirection.Rear; private currentDirection: CameraDirection = CameraDirection.Rear;
@ -52,6 +56,7 @@ export class CapacitorPlatformService implements PlatformService {
private isProcessingQueue: boolean = false; private isProcessingQueue: boolean = false;
constructor() { constructor() {
super();
this.sqlite = new SQLiteConnection(CapacitorSQLite); this.sqlite = new SQLiteConnection(CapacitorSQLite);
} }
@ -1328,79 +1333,8 @@ export class CapacitorPlatformService implements PlatformService {
// --- PWA/Web-only methods (no-op for Capacitor) --- // --- PWA/Web-only methods (no-op for Capacitor) ---
public registerServiceWorker(): void {} public registerServiceWorker(): void {}
// Database utility methods // Database utility methods - inherited from BaseDatabaseService
generateInsertStatement( // generateInsertStatement, updateDefaultSettings, updateActiveDid,
model: Record<string, unknown>, // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
tableName: string, // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
): { sql: string; params: unknown[] } {
const keys = Object.keys(model);
const placeholders = keys.map(() => "?").join(", ");
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`;
const params = keys.map((key) => model[key]);
return { sql, params };
}
async updateDefaultSettings(
settings: Record<string, unknown>,
): Promise<void> {
const keys = Object.keys(settings);
const setClause = keys.map((key) => `${key} = ?`).join(", ");
const sql = `UPDATE settings SET ${setClause} WHERE id = 1`;
const params = keys.map((key) => settings[key]);
await this.dbExec(sql, params);
}
async updateActiveDid(did: string): Promise<void> {
await this.dbExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[did],
);
}
async insertNewDidIntoSettings(did: string): Promise<void> {
// Import constants dynamically to avoid circular dependencies
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } =
await import("@/constants/app");
// Use INSERT OR REPLACE to handle case where settings already exist for this DID
// This prevents duplicate accountDid entries and ensures data integrity
await this.dbExec(
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)",
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER],
);
}
async updateDidSpecificSettings(
did: string,
settings: Record<string, unknown>,
): Promise<void> {
const keys = Object.keys(settings);
const setClause = keys.map((key) => `${key} = ?`).join(", ");
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
const params = [...keys.map((key) => settings[key]), did];
await this.dbExec(sql, params);
}
async retrieveSettingsForActiveAccount(): Promise<Record<
string,
unknown
> | null> {
const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1");
if (result?.values?.[0]) {
// Convert the row to an object
const row = result.values[0];
const columns = result.columns || [];
const settings: Record<string, unknown> = {};
columns.forEach((column, index) => {
if (column !== "id") {
// Exclude the id column
settings[column] = row[index];
}
});
return settings;
}
return null;
}
} }

112
src/services/platforms/WebPlatformService.ts

@ -5,6 +5,7 @@ import {
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database"; import { QueryExecResult } from "@/interfaces/database";
import { BaseDatabaseService } from "./BaseDatabaseService";
// Dynamic import of initBackend to prevent worker context errors // Dynamic import of initBackend to prevent worker context errors
import type { import type {
WorkerRequest, WorkerRequest,
@ -29,7 +30,10 @@ import type {
* Note: File system operations are not available in the web platform * Note: File system operations are not available in the web platform
* due to browser security restrictions. These methods throw appropriate errors. * due to browser security restrictions. These methods throw appropriate errors.
*/ */
export class WebPlatformService implements PlatformService { export class WebPlatformService
extends BaseDatabaseService
implements PlatformService
{
private static instanceCount = 0; // Debug counter private static instanceCount = 0; // Debug counter
private worker: Worker | null = null; private worker: Worker | null = null;
private workerReady = false; private workerReady = false;
@ -46,6 +50,7 @@ export class WebPlatformService implements PlatformService {
private readonly messageTimeout = 30000; // 30 seconds private readonly messageTimeout = 30000; // 30 seconds
constructor() { constructor() {
super();
WebPlatformService.instanceCount++; WebPlatformService.instanceCount++;
logger.debug("[WebPlatformService] Initializing web platform service"); logger.debug("[WebPlatformService] Initializing web platform service");
@ -668,105 +673,8 @@ export class WebPlatformService implements PlatformService {
// SharedArrayBuffer initialization is handled by initBackend call in initializeWorker // SharedArrayBuffer initialization is handled by initBackend call in initializeWorker
} }
// Database utility methods // Database utility methods - inherited from BaseDatabaseService
generateInsertStatement( // generateInsertStatement, updateDefaultSettings, updateActiveDid,
model: Record<string, unknown>, // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
tableName: string, // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
): { sql: string; params: unknown[] } {
const keys = Object.keys(model);
const placeholders = keys.map(() => "?").join(", ");
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`;
const params = keys.map((key) => model[key]);
return { sql, params };
}
async updateDefaultSettings(
settings: Record<string, unknown>,
): Promise<void> {
// Get current active DID and update that identity's settings
const activeIdentity = await this.getActiveIdentity();
const activeDid = activeIdentity.activeDid;
if (!activeDid) {
logger.warn(
"[WebPlatformService] No active DID found, cannot update default settings",
);
return;
}
const keys = Object.keys(settings);
const setClause = keys.map((key) => `${key} = ?`).join(", ");
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
const params = [...keys.map((key) => settings[key]), activeDid];
await this.dbExec(sql, params);
}
async updateActiveDid(did: string): Promise<void> {
await this.dbExec(
"INSERT OR REPLACE INTO active_identity (id, activeDid, lastUpdated) VALUES (1, ?, ?)",
[did, new Date().toISOString()],
);
}
async getActiveIdentity(): Promise<{ activeDid: string }> {
const result = await this.dbQuery(
"SELECT activeDid FROM active_identity WHERE id = 1",
);
return {
activeDid: (result?.values?.[0]?.[0] as string) || "",
};
}
async insertNewDidIntoSettings(did: string): Promise<void> {
// Import constants dynamically to avoid circular dependencies
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } =
await import("@/constants/app");
// Use INSERT OR REPLACE to handle case where settings already exist for this DID
// This prevents duplicate accountDid entries and ensures data integrity
await this.dbExec(
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)",
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER],
);
}
async updateDidSpecificSettings(
did: string,
settings: Record<string, unknown>,
): Promise<void> {
const keys = Object.keys(settings);
const setClause = keys.map((key) => `${key} = ?`).join(", ");
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
const params = [...keys.map((key) => settings[key]), did];
// Log update operation for debugging
logger.debug(
"[WebPlatformService] updateDidSpecificSettings",
sql,
JSON.stringify(params, null, 2),
);
await this.dbExec(sql, params);
}
async retrieveSettingsForActiveAccount(): Promise<Record<
string,
unknown
> | null> {
const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1");
if (result?.values?.[0]) {
// Convert the row to an object
const row = result.values[0];
const columns = result.columns || [];
const settings: Record<string, unknown> = {};
columns.forEach((column, index) => {
if (column !== "id") {
// Exclude the id column
settings[column] = row[index];
}
});
return settings;
}
return null;
}
} }

7
src/views/AccountViewView.vue

@ -1488,7 +1488,9 @@ export default class AccountViewView extends Vue {
status?: number; status?: number;
}; };
}; };
logger.error("[Server Limits] Error retrieving limits:", { logger.warn(
"[Server Limits] Error retrieving limits, expected for unregistered users:",
{
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
did: did, did: did,
apiServer: this.apiServer, apiServer: this.apiServer,
@ -1499,7 +1501,8 @@ export default class AccountViewView extends Vue {
httpStatus: axiosError?.response?.status, httpStatus: axiosError?.response?.status,
needsUserMigration: true, needsUserMigration: true,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); },
);
// this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD); // this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD);
} finally { } finally {

4
src/views/ContactEditView.vue

@ -346,9 +346,7 @@ export default class ContactEditView extends Vue {
// Notify success and redirect // Notify success and redirect
this.notify.success(NOTIFY_CONTACT_SAVED.message, TIMEOUTS.STANDARD); this.notify.success(NOTIFY_CONTACT_SAVED.message, TIMEOUTS.STANDARD);
(this.$router as Router).push({ this.$router.back();
path: "/did/" + encodeURIComponent(this.contact?.did || ""),
});
} }
} }
</script> </script>

8
src/views/ContactsView.vue

@ -171,9 +171,11 @@ import {
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD, CONTACT_URL_PATH_ENDORSER_CH_OLD,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { GiveSummaryRecord } from "@/interfaces/records"; import {
import { UserInfo } from "@/interfaces/common"; GiveSummaryRecord,
import { VerifiableCredential } from "@/interfaces/claims-result"; UserInfo,
VerifiableCredential,
} from "@/interfaces";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { import {
generateSaveAndActivateIdentity, generateSaveAndActivateIdentity,

14
src/views/DIDView.vue

@ -12,20 +12,20 @@
</h1> </h1>
<!-- Back --> <!-- Back -->
<router-link <button
class="order-first text-lg text-center leading-none p-1" class="order-first text-lg text-center leading-none p-1"
:to="{ name: 'contacts' }" @click="goBack()"
> >
<font-awesome icon="chevron-left" class="block text-center w-[1em]" /> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</router-link> </button>
<!-- Help button --> <!-- Help button -->
<router-link <button
: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" 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="goToHelp()"
> >
<font-awesome icon="question" class="block text-center w-[1em]" /> <font-awesome icon="question" class="block text-center w-[1em]" />
</router-link> </button>
</div> </div>
<!-- Identity Details --> <!-- Identity Details -->
@ -476,7 +476,7 @@ export default class DIDView extends Vue {
* Navigation helper methods * Navigation helper methods
*/ */
goBack() { goBack() {
this.$router.go(-1); this.$router.back();
} }
/** /**

2
src/views/HomeView.vue

@ -706,7 +706,7 @@ export default class HomeView extends Vue {
}; };
logger.warn( logger.warn(
"[HomeView Settings Trace] ⚠️ Registration check failed", "[HomeView Settings Trace] ⚠️ Registration check failed, expected for unregistered users.",
{ {
error: errorMessage, error: errorMessage,
did: this.activeDid, did: this.activeDid,

2
src/views/OnboardMeetingListView.vue

@ -77,7 +77,7 @@
v-if="meetings.length === 0 && !isRegistered" v-if="meetings.length === 0 && !isRegistered"
class="text-center text-gray-500 py-8" class="text-center text-gray-500 py-8"
> >
No onboarding meetings available No onboarding meetings are available
</p> </p>
</div> </div>

8
src/views/OnboardMeetingSetupView.vue

@ -473,6 +473,7 @@ export default class OnboardMeetingView extends Vue {
); );
return; return;
} }
const password: string = this.newOrUpdatedMeetingInputs.password;
// create content with user's name & DID encrypted with password // create content with user's name & DID encrypted with password
const content = { const content = {
@ -482,7 +483,7 @@ export default class OnboardMeetingView extends Vue {
}; };
const encryptedContent = await encryptMessage( const encryptedContent = await encryptMessage(
JSON.stringify(content), JSON.stringify(content),
this.newOrUpdatedMeetingInputs.password, password,
); );
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid);
@ -505,6 +506,11 @@ export default class OnboardMeetingView extends Vue {
this.newOrUpdatedMeetingInputs = null; this.newOrUpdatedMeetingInputs = null;
this.notify.success(NOTIFY_MEETING_CREATED.message, TIMEOUTS.STANDARD); this.notify.success(NOTIFY_MEETING_CREATED.message, TIMEOUTS.STANDARD);
// redirect to the same page with the password parameter set
this.$router.push({
name: "onboard-meeting-setup",
query: { password: password },
});
} else { } else {
throw { response: response }; throw { response: response };
} }

84
test-playwright/testUtils.ts

@ -49,6 +49,10 @@ export async function importUserFromAccount(page: Page, id?: string): Promise<st
await page.getByRole("button", { name: "Import" }).click(); await page.getByRole("button", { name: "Import" }).click();
// PHASE 1 FIX: Wait for registration status to settle
// This ensures that components have the correct isRegistered status
await waitForRegistrationStatusToSettle(page);
return userZeroData.did; return userZeroData.did;
} }
@ -69,6 +73,11 @@ export async function importUser(page: Page, id?: string): Promise<string> {
await expect( await expect(
page.locator("#sectionUsageLimits").getByText("Checking") page.locator("#sectionUsageLimits").getByText("Checking")
).toBeHidden(); ).toBeHidden();
// PHASE 1 FIX: Wait for registration check to complete and update UI elements
// This ensures that components like InviteOneView have the correct isRegistered status
await waitForRegistrationStatusToSettle(page);
return did; return did;
} }
@ -337,3 +346,78 @@ export function getElementWaitTimeout(): number {
export function getPageLoadTimeout(): number { export function getPageLoadTimeout(): number {
return getAdaptiveTimeout(30000, 1.4); return getAdaptiveTimeout(30000, 1.4);
} }
/**
* PHASE 1 FIX: Wait for registration status to settle
*
* This function addresses the timing issue where:
* 1. User imports identity Database shows isRegistered: false
* 2. HomeView loads Starts async registration check
* 3. Other views load Use cached isRegistered: false
* 4. Async check completes Updates database to isRegistered: true
* 5. But other views don't re-check → Plus buttons don't appear
*
* This function waits for the async registration check to complete
* without interfering with test navigation.
*/
export async function waitForRegistrationStatusToSettle(page: Page): Promise<void> {
try {
// Wait for the initial registration check to complete
// This is indicated by the "Checking" text disappearing from usage limits
await expect(
page.locator("#sectionUsageLimits").getByText("Checking")
).toBeHidden({ timeout: 15000 });
// Before navigating back to the page, we'll trigger a registration check
// by navigating to home and waiting for the registration process to complete
const currentUrl = page.url();
// Navigate to home to trigger the registration check
await page.goto('./');
await page.waitForLoadState('networkidle');
// Wait for the registration check to complete by monitoring the usage limits section
// This ensures the async registration check has finished
await page.waitForFunction(() => {
const usageLimits = document.querySelector('#sectionUsageLimits');
if (!usageLimits) return true; // No usage limits section, assume ready
// Check if the "Checking..." spinner is gone
const checkingSpinner = usageLimits.querySelector('.fa-spin');
if (checkingSpinner) return false; // Still loading
// Check if we have actual content (not just the spinner)
const hasContent = usageLimits.querySelector('p') || usageLimits.querySelector('button');
return hasContent !== null; // Has actual content, not just spinner
}, { timeout: 10000 });
// Also navigate to account page to ensure activeDid is set and usage limits are loaded
await page.goto('./account');
await page.waitForLoadState('networkidle');
// Wait for the usage limits section to be visible and loaded
await page.waitForFunction(() => {
const usageLimits = document.querySelector('#sectionUsageLimits');
if (!usageLimits) return false; // Section should exist on account page
// Check if the "Checking..." spinner is gone
const checkingSpinner = usageLimits.querySelector('.fa-spin');
if (checkingSpinner) return false; // Still loading
// Check if we have actual content (not just the spinner)
const hasContent = usageLimits.querySelector('p') || usageLimits.querySelector('button');
return hasContent !== null; // Has actual content, not just spinner
}, { timeout: 15000 });
// Navigate back to the original page if it wasn't home
if (!currentUrl.includes('/')) {
await page.goto(currentUrl);
await page.waitForLoadState('networkidle');
}
} catch (error) {
// Registration status check timed out, continuing anyway
// This may indicate the user is not registered or there's a server issue
}
}

Loading…
Cancel
Save