Compare commits

..

34 Commits

Author SHA1 Message Date
Jose Olarte III
223031866b refactor: remove unused loadMoreCallback prop from EntityGrid
Remove loadMoreCallback prop and related backward compatibility code.
No parent components were using this prop, and it has been superseded
by the internal pagination mechanism using fetchProjects() and beforeId.
2025-11-17 19:58:55 +08:00
Jose Olarte III
cb75b25529 refactor: consolidate project loading into EntityGrid component
Unify project loading and searching logic in EntityGrid.vue to eliminate
duplication. Make entities prop optional for projects, add internal
project state, and auto-load projects when needed.

- EntityGrid: Combine search/load into fetchProjects(), add internal
  allProjects state, handle pagination internally for both search and
  load modes
- OnboardMeetingSetupView: Remove project loading methods
- MeetingProjectDialog: Remove project props
- GiftedDialog: Remove project loading logic
- EntitySelectionStep: Make projects prop optional

Reduces code duplication by ~150 lines and simplifies component APIs.
All project selection now uses EntityGrid's internal loading.
2025-11-17 19:49:17 +08:00
Jose Olarte III
acf104eaa7 refactor: remove debug loggers from EntityGrid component
Remove three logger.debug() calls used for debugging project search
results and pagination state. Error logging remains intact.
2025-11-13 21:41:34 +08:00
Jose Olarte III
e793d7a9e2 refactor: defer project loading until MeetingProjectDialog opens
- Move loadProjects() call from created() to handleDialogOpen()
- Remove allProjects check from ensureSelectedProjectLoaded()
- Projects now load only when dialog is opened, improving initial page load performance
- ensureSelectedProjectLoaded() now directly fetches project by handleId when needed
2025-11-13 21:28:47 +08:00
Jose Olarte III
3ecae0be0f refactor(OnboardMeetingSetupView): fix selected project display after refresh
Refactor selectedProject computation to use separate storage instead of
relying on allProjects array. This fixes a bug where the selected project
wouldn't display after page refresh if it wasn't in the initial allProjects
batch.

Changes:
- Add selectedProjectData property to store selected project independently
- Simplify selectedProject computed to return selectedProjectData directly
- Add fetchProjectByHandleId() to fetch single project by handleId
- Add ensureSelectedProjectLoaded() to check allProjects first, then fetch
- Update handleProjectLinkAssigned() to store directly in selectedProjectData
- Remove band-aid solution of adding selected projects to allProjects array
- Update startEditing() and cancelEditing() to ensure selected project loads
- Call ensureSelectedProjectLoaded() in created() lifecycle hook

This ensures the selected project always displays correctly, even when:
- Selected from search results (not in allProjects)
- Page is refreshed (allProjects reloads without selected project)
- Project is in a later pagination batch
2025-11-13 19:21:22 +08:00
Jose Olarte III
d37e53b1a9 fix: pause MembersList auto-refresh during project dialog interaction
Stop auto-refresh when MeetingProjectDialog opens and resume when it closes
to prevent UI conflicts during project selection.
2025-11-13 18:10:35 +08:00
Jose Olarte III
2f89c7e13b feat(EntityGrid): add server-side search with pagination for projects
Implement server-side search for projects using API endpoint with
pagination support via beforeId parameter. Contacts continue using
client-side filtering from complete local database.

- Add PlatformServiceMixin for internal apiServer access
- Implement performProjectSearch() with pagination
- Update infinite scroll to handle search pagination
- Add search lifecycle management and error handling

No breaking changes to parent components.
2025-11-12 21:06:20 +08:00
Jose Olarte III
6bf4055c2f feat: add pagination support for project lists in dialogs
Add server-side pagination to EntityGrid component for projects, enabling
infinite scrolling to load all available projects instead of stopping after
the initial batch.

Changes:
- EntityGrid: Add loadMoreCallback prop to trigger server-side loading when
  scroll reaches end of loaded projects
- OnboardMeetingSetupView: Update loadProjects() to support pagination with
  beforeId parameter and add handleLoadMoreProjects() callback
- MeetingProjectDialog: Accept and pass through loadMoreCallback to EntityGrid
- GiftedDialog: Add pagination support to loadProjects() and
  handleLoadMoreProjects() callback
- EntitySelectionStep: Accept and pass through loadMoreCallback prop to
  EntityGrid when showing projects

This ensures users can access all projects in MeetingProjectDialog and
GiftedDialog by automatically loading more as they scroll, matching the
behavior already present in DiscoverView.

All project uses of EntityGrid now use pagination by default.
2025-11-12 17:10:03 +08:00
Jose Olarte III
bf7ee630d0 feat(meeting): enable selecting all projects in meeting setup
Update loadProjects to fetch all projects instead of only user's projects
by switching from plansByIssuer to plans endpoint.
2025-11-12 15:58:23 +08:00
Jose Olarte III
a5a9af5ddc feat(meetings): add project selection dialog for meeting setup
Replace Project Link text input with interactive selection dialog
using new MeetingProjectDialog component. Dialog displays user's
projects with icons and issuer information, following the same
pattern as ProjectRepresentativeDialog.

- Create MeetingProjectDialog with EntityGrid integration
- Add clickable project field with icon, name, and issuer display
- Load projects from /api/v2/report/plansByIssuer endpoint
- Show issuer name instead of handleId for better UX
- Refactor loadProjects to remove unused rowId field
2025-11-11 21:34:11 +08:00
Jose Olarte III
4e3e293495 refactor(EntityGrid): simplify alphabetical section label
Change "Everyone Else" to "Everyone" for clearer, more concise labeling
2025-11-11 15:32:11 +08:00
Jose Olarte III
65533c15d2 Merge branch 'project-representative-dialog' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into project-representative-dialog 2025-11-11 15:15:01 +08:00
Jose Olarte III
2530bc0ec2 fix: ensure consistent "Recently Added" contacts in ProjectRepresentativeDialog
EntityGrid's recentContacts assumes contacts are sorted by date added
(newest first), but ProjectRepresentativeDialog was receiving contacts
sorted alphabetically from NewEditProjectView, causing it to show
different "Recently Added" contacts than GiftedDialog.

- Changed NewEditProjectView to use $contactsByDateAdded() instead of
  $getAllContacts()
- Added documentation comments to EntityGrid.vue to prevent this issue
  in future reuses
2025-11-11 15:06:07 +08:00
b1fa6ac458 feat: show the recent contacts in the alphabetical section of choosers 2025-11-07 18:27:05 -07:00
9ff24f8258 fix: in project-edit view, don't show agent warning on new one, and automatically switch if they're changing 2025-11-07 18:11:11 -07:00
Jose Olarte III
9a3409c29f refactor: remove unused code from ProjectRepresentativeDialog
- Remove conflictChecker prop (always passed as no-op function)
- Remove unused emitCancel method and cancel event handling
- Simplify handleEntitySelected by removing unnecessary type check
- Update NewEditProjectView to remove conflict-checker binding and empty cancel handler

The conflictChecker prop was not needed since representative selection
doesn't require conflict detection. The cancel event was never emitted
and the parent handler was empty, so both were removed.
2025-11-07 17:43:44 +08:00
Jose Olarte III
a142737771 feat: replace authorized representative input with contact selection dialog
Replace the plain text input for authorized representative with an
interactive contact selection interface that provides better UX and
maintains data consistency.

Changes:
- Add ProjectRepresentativeDialog component using EntityGrid for contact selection (excludes "You" and "Unnamed" special entities)
- Replace text input with clickable field showing contact icon, name, and DID
- Implement conditional UI states: initial "Assign..." placeholder vs assigned representative display with unset button
- Refactor selectedRepresentative to computed property derived from agentDid (single source of truth, prevents sync issues)
- Inline representativeDisplayName for simplicity
- Support changing representative by clicking on assigned field
- Support unsetting representative via trash button

The new implementation ensures agentDid remains the authoritative state while selectedRepresentative is automatically computed, preventing the previously possible desync when agentDid was set directly (e.g., via the
"make original owner an authorized representative" button).
2025-11-05 20:20:43 +08:00
1053bb6e4c Merge pull request 'bulk-members-dialog-refactor' (#218) from bulk-members-dialog-refactor into master
Reviewed-on: #218
2025-11-05 03:34:27 -05:00
88f46787e5 Merge pull request 'entity-selection-list-component' (#216) from entity-selection-list-component into master
Reviewed-on: #216
2025-11-05 03:25:35 -05:00
Jose Olarte III
d9230d0be8 fix: restore proper dialog max-height 2025-11-05 16:25:06 +08:00
Jose Olarte III
38f301f053 Merge branch 'master' into entity-selection-list-component 2025-11-05 16:12:39 +08:00
e42552c67a Merge pull request 'feat(EntityGrid): implement infinite scroll for entity lists' (#215) from entity-selection-list-component-infinite-scroll into entity-selection-list-component
Reviewed-on: #215
2025-11-05 02:52:30 -05:00
Jose Olarte III
c06ffec466 refactor: combine member processing methods in BulkMembersDialog
Consolidate organizerAdmitAndAddWithVisibility() and
memberAddContactWithVisibility() into a single unified method
processSelectedMembers() that handles both organizer and member
modes based on the isOrganizer prop.

- Remove redundant handleMainAction() wrapper method
- Update template to call processSelectedMembers directly
- Reduce code duplication by ~30% (140 lines → 98 lines)
- Maintain identical functionality for both modes

This simplifies the component structure and makes the processing
logic easier to maintain.
2025-11-04 18:39:45 +08:00
Jose Olarte III
8b199ec76c refactor: remove redundant dialogType prop from BulkMembersDialog
Remove dialogType prop and consolidate to use only isOrganizer prop.

- Remove dialogType prop from BulkMembersDialog component
- Replace all dialogType checks with isOrganizer boolean checks
- Add comments clarifying isOrganizer true/false meanings
- Remove dialog-type prop binding from MembersList component

This simplifies the component API while maintaining the same functionality.
2025-11-04 17:57:38 +08:00
Jose Olarte III
d32cca4f53 feat(EntityGrid): implement infinite scroll for entity lists
Add infinite scroll functionality to EntityGrid component using VueUse's
useInfiniteScroll composable to handle large volumes of entities efficiently.

Changes:
- Integrate @vueuse/core useInfiniteScroll composable
- Add infinite scroll state management (displayedCount, reset function)
- Configure initial batch size (20 items) and increment size (20 items)
- Update displayedEntities, alphabeticalContacts to support progressive loading
- Add canLoadMore() logic for people, projects, and search modes
- Reset scroll state when search term or entities prop changes
- Remove maxItems prop (replaced by infinite scroll)
- Simplify displayEntitiesFunction signature (removed maxItems parameter)
- Update EntitySelectionStep and test files to remove max-items prop

Technical details:
- Uses template ref (scrollContainer) to access scrollable container
- Recent contacts (3) count toward initial batch for people grid
- Special entities (You, Unnamed) always displayed, don't count toward limits
- Infinite scroll works for both entity types and search results
- Constants are configurable at top of component (INITIAL_BATCH_SIZE, INCREMENT_SIZE)

This improves performance and UX when displaying large lists of contacts or
projects by loading content progressively as users scroll.
2025-11-03 21:47:25 +08:00
Jose Olarte III
4004d9fe52 feat(EntityGrid): Split contacts into recent and alphabetical sections
When displaying contacts (not search results), show the 3 most recently
added contacts at the top with a "Recently Added" heading, followed by
the rest sorted alphabetically with an "Everyone Else" heading.

- Add recentContacts and alphabeticalContacts computed properties
- Hide "You" and "Unnamed" special entities during search
- Only show search spinner when actively searching with a term
- Style section headings with uppercase, improved spacing, and borders
2025-11-03 16:32:59 +08:00
Jose Olarte III
2f99d0b416 fix(components): prevent icon shrinking in PersonCard and ProjectCard
Add shrink-0 class to icon elements to maintain consistent icon sizing
when card layouts flex or wrap content.
2025-10-31 19:10:13 +08:00
Jose Olarte III
9c3002f9c7 feat(EntityGrid): sort search results alphabetically
Sort search results alphabetically while preserving original order for
default list when no search term is present.
2025-10-31 19:07:50 +08:00
Jose Olarte III
82fd7cddf7 feat: Add showUnnamedEntity prop to EntityGrid
Add prop to control visibility of "Unnamed" entity, matching showYouEntity
pattern. Defaults to true for backward compatibility.
2025-10-31 18:59:35 +08:00
Jose Olarte III
10f2920e11 feat(EntityGrid): display no results message for empty search queries
Add contextual feedback message when a search term is entered but no matching entities are found. The message dynamically adjusts its wording based on whether searching for people or projects.
2025-10-31 18:34:54 +08:00
Jose Olarte III
75c89b471c fix: linting 2025-10-30 21:49:35 +08:00
Jose Olarte III
a804877a08 feat: Add quick search to EntityGrid with date-based contact sorting
- Add search-as-you-type functionality with 500ms debounce
- Implement search across contact names and DIDs, project names and handleIds
- Add loading spinner and dynamic clear button
- Add $contactsByDateAdded() method to PlatformServiceMixin for newest-first sorting
- Update GiftedDialog to use date-based contact ordering
- Maintain backward compatibility with existing $contacts() alphabetical sorting
- Add proper cleanup for search timeouts on component unmount

The search feature provides real-time filtering with visual feedback,
while the new sorting ensures recently added contacts appear first.
2025-10-30 21:16:36 +08:00
Jose Olarte III
f7441f39e7 feat: remove Show All navigation card from entity grids
- Remove ShowAllCard component and all related functionality
- Remove showAllRoute, showAllQueryParams, and hideShowAll props
- Remove shouldShowAll computed property from EntityGrid
- Clean up ShowAll-related code from EntitySelectionStep and GiftedDialog
- Delete ShowAllCard.vue component file
- Update component documentation to reflect removal

This simplifies the entity selection interface by removing the navigation
card that allowed users to view all entities in a separate view.
2025-10-30 17:31:18 +08:00
Jose Olarte III
e647af0777 refactor: convert entity display to list style
- Switch from grid display to list layout for persons and projects
- Re-styled special entities (unnamed, You) to match
- Added max-height limit to list in preparation for scrolling and displaying more items
2025-10-24 13:26:36 +08:00
31 changed files with 1436 additions and 3257 deletions

View File

@@ -18,7 +18,6 @@ dependencies {
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capawesome-capacitor-file-picker')
implementation project(':timesafari-daily-notification-plugin')
}

View File

@@ -36,30 +36,6 @@
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
<!-- Daily Notification Plugin Receivers -->
<receiver
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.timesafari.daily.NOTIFICATION" />
</intent-filter>
</receiver>
<receiver
android:name="com.timesafari.dailynotification.BootReceiver"
android:directBootAware="true"
android:enabled="true"
android:exported="true">
<intent-filter android:priority="1000">
<!-- Delivered very early after reboot (before unlock) -->
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<!-- Delivered after the user unlocks / credential-encrypted storage is available -->
<action android:name="android.intent.action.BOOT_COMPLETED" />
<!-- Delivered after app update; great for rescheduling alarms without reboot -->
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
<!-- Permissions -->
@@ -69,15 +45,4 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<!-- Notification permissions -->
<!-- POST_NOTIFICATIONS required for Android 13+ (API 33+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- SCHEDULE_EXACT_ALARM required for Android 12+ (API 31+) to schedule exact alarms -->
<!-- Note: On Android 12+, users can grant/deny this permission -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- RECEIVE_BOOT_COMPLETED needed to reschedule notifications after device reboot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
</manifest>

View File

@@ -34,9 +34,5 @@
{
"pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
},
{
"pkg": "@timesafari/daily-notification-plugin",
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]

View File

@@ -28,6 +28,3 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
include ':timesafari-daily-notification-plugin'
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')

File diff suppressed because it is too large Load Diff

42
package-lock.json generated
View File

@@ -37,7 +37,6 @@
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
@@ -150,43 +149,6 @@
"vite": "^5.2.0"
}
},
"../daily-notification-plugin": {
"name": "@timesafari/daily-notification-plugin",
"version": "1.0.1",
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@capacitor/core": "^6.2.1"
},
"devDependencies": {
"@capacitor/android": "^6.2.1",
"@capacitor/cli": "^6.2.1",
"@capacitor/ios": "^6.2.1",
"@types/jest": "^29.5.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.19.0",
"@typescript-eslint/eslint-plugin": "^5.57.0",
"@typescript-eslint/parser": "^5.57.0",
"eslint": "^8.37.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^30.0.5",
"jsdom": "^26.1.0",
"markdownlint-cli2": "^0.18.1",
"prettier": "^2.8.7",
"rimraf": "^4.4.0",
"rollup": "^3.20.0",
"rollup-plugin-typescript2": "^0.31.0",
"standard-version": "^9.5.0",
"ts-jest": "^29.1.0",
"typescript": "~5.2.0",
"vite": "^7.1.9"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@0no-co/graphql.web": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
@@ -9643,10 +9605,6 @@
"node": ">=10"
}
},
"node_modules/@timesafari/daily-notification-plugin": {
"resolved": "../daily-notification-plugin",
"link": true
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",

View File

@@ -166,7 +166,6 @@
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",

View File

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

View File

@@ -111,7 +111,7 @@
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="handleMainAction"
@click="processSelectedMembers"
>
{{ buttonText }}
</button>
@@ -145,7 +145,7 @@ import { Contact } from "@/db/tables/contacts";
export default class BulkMembersDialog extends Vue {
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
@Prop({ required: true }) dialogType!: "admit" | "visibility";
// isOrganizer: true = organizer mode (admit members), false = member mode (set visibility)
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system
@@ -252,15 +252,7 @@ export default class BulkMembersDialog extends Vue {
return this.selectedMembers.includes(memberDid);
}
async handleMainAction() {
if (this.dialogType === "admit") {
await this.organizerAdmitAndAddWithVisibility();
} else {
await this.memberAddContactWithVisibility();
}
}
async organizerAdmitAndAddWithVisibility() {
async processSelectedMembers() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
@@ -275,16 +267,20 @@ export default class BulkMembersDialog extends Vue {
for (const member of selectedMembers) {
try {
// First, admit the member
await this.admitMember(member);
// Register them
await this.registerMember(member);
admittedCount++;
// Organizer mode: admit and register the member first
if (this.isOrganizer) {
await this.admitMember(member);
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);
// Organizer mode: set isRegistered to true, member mode: undefined
await this.addAsContact(
member,
this.isOrganizer ? true : undefined,
);
contactAddedCount++;
}
@@ -299,88 +295,51 @@ export default class BulkMembersDialog extends Vue {
}
// 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
if (this.isOrganizer) {
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"}`}.`,
},
5000,
);
}
if (errors > 0) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to fully admit some members. Work with them individually below.",
},
5000,
);
}
} else {
// Member mode: show contacts added notification
if (contactAddedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added Successfully",
text: `${contactAddedCount} member${contactAddedCount === 1 ? "" : "s"} added as contact${contactAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
}
}
// 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);
console.error(
`Error ${this.isOrganizer ? "admitting members" : "adding contacts"}:`,
error,
);
this.$notify(
{
group: "alert",
@@ -487,10 +446,10 @@ export default class BulkMembersDialog extends Vue {
}
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.";
// isOrganizer: true = admit mode, false = visibility mode
const message = this.isOrganizer
? "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(
{

View File

@@ -2,12 +2,55 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */
<template>
<ul :class="gridClasses">
<!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
<input
v-model="searchTerm"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
@input="handleSearchInput"
@keydown.enter="performSearch"
/>
<div
v-show="isSearching && searchTerm"
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
>
<font-awesome
icon="spinner"
class="fa-spin-pulse leading-[1.1]"
></font-awesome>
</div>
<button
:disabled="!searchTerm"
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
@click="clearSearch"
>
<font-awesome
:icon="searchTerm ? 'times' : 'magnifying-glass'"
class="fa-fw"
></font-awesome>
</button>
</div>
<div
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
class="mb-4 text-sm italic text-slate-500 text-center"
>
{{ searchTerm }} doesn't match any
{{ entityType === "people" ? "people" : "projects" }}. Try a different
search.
</div>
<ul
ref="scrollContainer"
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
>
<!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'">
<!-- "You" entity -->
<SpecialEntityCard
v-if="showYouEntity"
v-if="showYouEntity && !searchTerm.trim()"
entity-type="you"
label="You"
icon="hand"
@@ -21,6 +64,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity -->
<SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed"
:label="unnamedEntityName"
icon="circle-question"
@@ -32,22 +76,66 @@ projects, and special entities with selection. * * @author Matthew Raymer */
</template>
<!-- Empty state message -->
<li v-if="entities.length === 0" :class="emptyStateClasses">
<li v-if="hasNoEntities" :class="emptyStateClasses">
{{ emptyStateMessage }}
</li>
<!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'">
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
<!-- When showing contacts without search: split into recent and alphabetical -->
<template v-if="!searchTerm.trim()">
<!-- Recently Added Section -->
<template v-if="recentContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Added
</li>
<PersonCard
v-for="person in recentContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<!-- Alphabetical Section -->
<template v-if="alphabeticalContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone
</li>
<PersonCard
v-for="person in alphabeticalContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<template v-else-if="entityType === 'projects'">
@@ -63,27 +151,30 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected"
/>
</template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { getHeaders } from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { TIMEOUTS } from "@/utils/notify";
/**
* Constants for infinite scroll configuration
*/
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
/**
* EntityGrid - Unified grid layout for displaying people or projects
@@ -93,7 +184,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
* - Special entity integration (You, Unnamed)
* - Conflict detection integration
* - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties
@@ -104,21 +194,49 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
PersonCard,
ProjectCard,
SpecialEntityCard,
ShowAllCard,
},
mixins: [PlatformServiceMixin],
})
export default class EntityGrid extends Vue {
/** Type of entities to display */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Array of entities to display */
@Prop({ required: true })
entities!: Contact[] | PlanData[];
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
// API server for project searches
apiServer = "";
// Internal project state (when entities prop not provided for projects)
allProjects: PlanData[] = [];
loadBeforeId: string | undefined = undefined;
isLoadingProjects = false;
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/**
* Array of entities to display
*
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
* Use $contactsByDateAdded() to ensure all contacts are included.
* Client-side filtering assumes the complete list is available.
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
* (newest first) for the "Recently Added" section to display correctly.
*
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
* projects internally from the API server. If provided, uses the provided list.
*/
@Prop({ required: false })
entities?: Contact[] | PlanData[];
/** Active user's DID */
@Prop({ required: true })
@@ -140,18 +258,14 @@ export default class EntityGrid extends Vue {
@Prop({ default: true })
showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */
@Prop({ default: true })
youSelectable!: boolean;
/** Route name for "Show All" navigation */
@Prop({ default: "" })
showAllRoute!: string;
/** Query parameters for "Show All" navigation */
@Prop({ default: () => ({}) })
showAllQueryParams!: Record<string, string>;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
@@ -160,42 +274,31 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* Function to determine which entities to display (allows parent control)
*
* This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering, sorting, and
* display logic beyond the default simple slice behavior.
* are displayed in the grid, enabling advanced filtering and sorting.
* Note: Infinite scroll is disabled when this prop is provided.
*
* @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects")
* @param maxItems - The maximum number of items to display (from maxItems prop)
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type, max) =>
* entities.filter(e => e.profileImageUrl).slice(0, max)"
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type, max) =>
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
*
* @example
* // Advanced logic: different limits for different entity types
* :display-entities-function="(entities, type, max) =>
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[];
/**
@@ -206,33 +309,98 @@ export default class EntityGrid extends Vue {
}
/**
* Computed CSS classes for the grid layout
* Check if there are no entities to display
*/
get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
get hasNoEntities(): boolean {
if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
// For projects: check internal state if no entities prop, otherwise check prop
const projectsToCheck = this.entities || this.allProjects;
return projectsToCheck.length === 0;
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
// For people: entities prop is required
return !this.entities || this.entities.length === 0;
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise defaults
* Get the entities array to use (prop or internal state)
*/
get entitiesToUse(): Contact[] | PlanData[] {
if (this.entityType === "projects") {
// For projects: use prop if provided, otherwise use internal state
return this.entities || this.allProjects;
} else {
// For people: entities prop is required
return this.entities || [];
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
*/
get displayedEntities(): Contact[] | PlanData[] {
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(
this.entities,
this.entityType,
this.maxItems,
);
// If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// Default implementation for backward compatibility
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return this.entities.slice(0, maxDisplay);
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Get the most recently added contacts (when showing contacts and not searching)
*
* NOTE: This assumes entities are already sorted by date added (newest first).
* See the entities prop documentation for details on using $contactsByDateAdded().
*/
get recentContacts(): Contact[] {
if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return [];
}
// Skip the first few (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = this.entities as Contact[];
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
}
/**
@@ -246,15 +414,6 @@ export default class EntityGrid extends Vue {
}
}
/**
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return (
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
);
}
/**
* Whether the "You" entity is conflicted
*/
@@ -328,6 +487,440 @@ export default class EntityGrid extends Vue {
});
}
/**
* Handle search input with debouncing
*/
handleSearchInput(): void {
// Show spinner immediately when user types
this.isSearching = true;
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for 500ms delay
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 500);
}
/**
* Perform the actual search
* Routes to server-side search for projects or client-side filtering for contacts
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
this.searchBeforeId = undefined; // Reset pagination for new search
try {
if (this.entityType === "projects") {
// Server-side search for projects (initial load, no beforeId)
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(undefined, searchLower);
} else {
// Client-side filtering for contacts (complete list)
await this.performContactSearch();
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Fetch projects from API server
* Unified method for both loading all projects and searching projects.
* If claimContents is provided, performs search and updates filteredEntities.
* If claimContents is not provided, loads all projects and updates allProjects.
*
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
*/
async fetchProjects(
beforeId?: string,
claimContents?: string,
): Promise<void> {
if (!this.apiServer) {
if (claimContents) {
this.filteredEntities = [];
} else {
this.allProjects = [];
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "API server not configured",
},
TIMEOUTS.SHORT,
);
}
return;
}
const isSearch = !!claimContents;
let url = `${this.apiServer}/api/v2/report/plans`;
// Build query parameters
const params: string[] = [];
if (claimContents) {
params.push(
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
);
}
if (beforeId) {
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
}
if (params.length > 0) {
url += `?${params.join("&")}`;
}
try {
const response = await fetch(url, {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error(
isSearch ? "Failed to search projects" : "Failed to load projects",
);
}
const results = await response.json();
if (results.data) {
const newProjects = results.data.map(
(plan: PlanData & { rowId?: string }) => ({
...plan,
rowId: plan.rowId,
}),
);
if (isSearch) {
// Search mode: update filteredEntities
if (beforeId) {
// Pagination: append new projects to existing search results
this.filteredEntities.push(...newProjects);
} else {
// Initial search: replace array
this.filteredEntities = newProjects;
}
// Update searchBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.searchBeforeId = lastProject.rowId || undefined;
} else {
this.searchBeforeId = undefined; // No more results
}
} else {
// Load mode: update allProjects
if (beforeId) {
// Pagination: append new projects
this.allProjects.push(...newProjects);
} else {
// Initial load: replace array
this.allProjects = newProjects;
}
// Update loadBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.loadBeforeId = lastProject.rowId || undefined;
} else {
this.loadBeforeId = undefined; // No more results
}
}
} else {
// No data in response
if (isSearch) {
if (!beforeId) {
// Only clear on initial search, not pagination
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load, not pagination
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
}
} catch (error) {
logger.error(
`Error ${isSearch ? "searching" : "loading"} projects:`,
error,
);
if (isSearch) {
if (!beforeId) {
// Only clear on initial search error, not pagination error
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load error, not pagination error
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: isSearch
? "Failed to search projects. Please try again."
: "Failed to load projects. Please try again.",
},
TIMEOUTS.STANDARD,
);
}
}
}
/**
* Client-side contact search
* Assumes entities prop contains complete contact list from local database
*/
async performContactSearch(): Promise<void> {
if (!this.entities) {
this.filteredEntities = [];
return;
}
// Simulate async (for consistency with project search)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Contacts don't need pagination (complete list)
this.searchBeforeId = undefined;
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// Clear any pending timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
/**
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// Search mode: check if more results available
if (this.entityType === "projects") {
// Projects: can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
const hasMoreLoaded =
this.displayedCount < this.filteredEntities.length;
const canLoadMoreFromServer =
this.displayedCount >= this.filteredEntities.length &&
!!this.searchBeforeId &&
!this.isLoadingSearchMore;
return hasMoreLoaded || canLoadMoreFromServer;
} else {
// Contacts: client-side filtering returns all results at once
return this.displayedCount < this.filteredEntities.length;
}
}
// Non-search mode
if (this.entityType === "projects") {
// Projects: check internal state or prop
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// Can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
const canLoadMoreFromServer =
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
!!beforeId &&
!this.isLoadingProjects;
return hasMoreLoaded || canLoadMoreFromServer;
}
// People: check if more alphabetical contacts available
// Total available = recent + all alphabetical
if (!this.entities) {
return false;
}
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
async mounted(): Promise<void> {
// Load apiServer for project searches/loads
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load projects on mount if entities prop not provided
if (!this.entities && this.apiServer) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error("Error loading projects on mount:", error);
} finally {
this.isLoadingProjects = false;
}
}
}
// Validate entities prop for people
if (this.entityType === "people" && !this.entities) {
logger.error(
"EntityGrid: entities prop is required when entityType is 'people'",
);
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Contacts data is required but not provided.",
},
TIMEOUTS.SHORT,
);
}
}
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
async () => {
// Search mode: handle search pagination
if (this.searchTerm.trim()) {
if (this.entityType === "projects") {
// Projects: load more search results if available
if (
this.displayedCount >= this.filteredEntities.length &&
this.searchBeforeId &&
!this.isLoadingSearchMore
) {
this.isLoadingSearchMore = true;
try {
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(this.searchBeforeId, searchLower);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more search results:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingSearchMore = false;
}
} else {
// Show more from already-loaded search results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Contacts: show more from already-filtered results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Non-search mode
if (this.entityType === "projects") {
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// If using internal state and need to load more from server
if (
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
beforeId &&
!this.isLoadingProjects
) {
this.isLoadingProjects = true;
try {
await this.fetchProjects(beforeId);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more projects:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingProjects = false;
}
} else {
// Normal case: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
} else {
// People: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
}
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
@@ -340,6 +933,47 @@ export default class EntityGrid extends Vue {
} {
return data;
}
/**
* Watch for changes in search term to reset displayed count and pagination
*/
@Watch("searchTerm")
onSearchTermChange(): void {
// Reset displayed count and pagination when search term changes
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to clear search and reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
// Clear search when entities change (fresh dialog open)
if (this.searchTerm) {
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
}
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// For projects: if entities prop is provided, clear internal state
if (this.entityType === "projects" && this.entities) {
this.allProjects = [];
this.loadBeforeId = undefined;
}
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
}
</script>

View File

@@ -3,10 +3,9 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Show All navigation with context preservation * - Cancel functionality * - Event
delegation for entity selection * - Warning notifications for conflicted
entities * - Template streamlined with computed CSS properties * * @author
Matthew Raymer */
Cancel functionality * - Event delegation for entity selection * - Warning
notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
@@ -15,19 +14,15 @@ Matthew Raymer */
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:entities="shouldShowProjects ? projects || undefined : allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify"
:conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
/>
@@ -68,7 +63,6 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display
* - Conflict detection and prevention
* - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
@@ -100,9 +94,9 @@ export default class EntitySelectionStep extends Vue {
@Prop({ default: false })
isFromProjectView!: boolean;
/** Array of available projects */
@Prop({ required: true })
projects!: PlanData[];
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
@Prop({ required: false })
projects?: PlanData[];
/** Array of available contacts */
@Prop({ required: true })
@@ -154,10 +148,6 @@ export default class EntitySelectionStep extends Vue {
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* CSS classes for the cancel button
*/
@@ -222,59 +212,6 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Route name for "Show All" navigation
*/
get showAllRoute(): string {
if (this.shouldShowProjects) {
return "discover";
} else if (this.allContacts.length > 0) {
return "contact-gift";
}
return "";
}
/**
* Query parameters for "Show All" navigation
*/
get showAllQueryParams(): Record<string, string> {
const baseParams = {
stepType: this.stepType,
giverEntityType: this.giverEntityType,
recipientEntityType: this.recipientEntityType,
// Form field values to preserve
description: this.description,
amountInput: this.amountInput,
unitCode: this.unitCode,
offerId: this.offerId,
fromProjectId: this.fromProjectId,
toProjectId: this.toProjectId,
showProjects: this.showProjects.toString(),
isFromProjectView: this.isFromProjectView.toString(),
};
if (this.shouldShowProjects) {
// For project contexts, still pass entity type information
return baseParams;
}
return {
...baseParams,
// Always pass both giver and recipient info for context preservation
giverProjectId: this.fromProjectId || "",
giverProjectName: this.giver?.name || "",
giverProjectImage: this.giver?.image || "",
giverProjectHandleId: this.giver?.handleId || "",
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
recipientProjectId: this.toProjectId || "",
recipientProjectName: this.receiver?.name || "",
recipientProjectImage: this.receiver?.image || "",
recipientProjectHandleId: this.receiver?.handleId || "",
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
};
}
/**
* Handle entity selection from EntityGrid
*/

View File

@@ -15,7 +15,6 @@
giverEntityType === 'project' || recipientEntityType === 'project'
"
:is-from-project-view="isFromProjectView"
:projects="projects"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
@@ -29,7 +28,6 @@
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
@cancel="cancel"
/>
@@ -69,7 +67,6 @@ import {
createAndSubmitGive,
didInfo,
serverMessageForUser,
getHeaders,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { Contact } from "../db/tables/contacts";
@@ -117,7 +114,6 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as
| "person"
| "project";
@@ -136,7 +132,6 @@ export default class GiftedDialog extends Vue {
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
offerId = "";
projects: PlanData[] = [];
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
stepType = "giver";
@@ -233,19 +228,9 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer,
});
this.allContacts = await this.$contacts();
this.allContacts = await this.$contactsByDateAdded();
this.allMyDids = await retrieveAccountDids();
if (
this.giverEntityType === "project" ||
this.recipientEntityType === "project"
) {
await this.loadProjects();
} else {
// Clear projects array when not needed
this.projects = [];
}
} catch (err: unknown) {
logger.error("Error retrieving settings from database:", err);
this.safeNotify.error(
@@ -491,27 +476,6 @@ export default class GiftedDialog extends Vue {
this.firstStep = false;
}
async loadProjects() {
try {
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error("Failed to load projects");
}
const results = await response.json();
if (results.data) {
this.projects = results.data;
}
} catch (error) {
logger.error("Error loading projects:", error);
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
}
}
selectProject(project: PlanData) {
this.giver = {
did: project.handleId,

View File

@@ -0,0 +1,130 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
<!-- EntityGrid for projects -->
<EntityGrid
:entity-type="'projects'"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'project'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
*
* Features:
* - EntityGrid integration for project selection
* - No special entities (You, Unnamed)
* - Immediate assignment on project selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected project and closes the dialog
*/
handleEntitySelected(event: {
type: "person" | "project";
data: Contact | PlanData;
}) {
const project = event.data as PlanData;
this.emitAssign(project);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
this.emitOpen();
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
this.emitClose();
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(project: PlanData): PlanData {
return project;
}
@Emit("open")
emitOpen(): void {
// Emit when dialog opens
}
@Emit("close")
emitClose(): void {
// Emit when dialog closes
}
}
</script>
<style scoped></style>

View File

@@ -223,7 +223,6 @@
ref="bulkMembersDialog"
:active-did="activeDid"
:api-server="apiServer"
:dialog-type="isOrganizer ? 'admit' : 'visibility'"
:is-organizer="isOrganizer"
@close="closeBulkMembersDialogCallback"
/>

View File

@@ -3,30 +3,25 @@ GiftedDialog.vue to handle person entity display * with selection states and
conflict detection. * * @author Matthew Raymer */
<template>
<li :class="cardClasses" @click="handleClick">
<div class="relative w-fit mx-auto">
<div>
<EntityIcon
v-if="person.did"
:contact="person"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-5xl mb-1"
class="text-slate-400 text-5xl mb-1 shrink-0"
/>
<!-- Time icon overlay for contacts -->
<div
v-if="person.did && showTimeIcon"
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
</div>
</div>
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<div class="overflow-hidden">
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
</div>
</li>
</template>
@@ -81,29 +76,32 @@ export default class PersonCard extends Vue {
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return "opacity-50 cursor-not-allowed";
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return "cursor-pointer hover:bg-slate-50";
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the person name
*/
get nameClasses(): string {
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
const baseNameClasses = "text-sm font-semibold truncate";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
return `${baseNameClasses} text-slate-500`;
}
// Add italic styling for entities without set names
if (!this.person.name) {
return `${baseClasses} italic text-slate-500`;
return `${baseNameClasses} italic text-slate-500`;
}
return baseClasses;
return baseNameClasses;
}
/**

View File

@@ -2,25 +2,26 @@
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer" @click="handleClick">
<div class="relative w-fit mx-auto">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
</div>
<li
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<ProjectIcon
:entity-id="project.handleId"
:icon-size="30"
:image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ project.name || unnamedProject }}
</h3>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
</h3>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ issuerDisplayName }}
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="text-slate-400" />
{{ issuerDisplayName }}
</div>
</div>
</li>
</template>

View File

@@ -0,0 +1,117 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">
Select Representative
</h2>
<!-- EntityGrid for contacts -->
<EntityGrid
:entity-type="'people'"
:entities="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'representative'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { NotificationIface } from "../constants/app";
/**
* ProjectRepresentativeDialog - Dialog for selecting an authorized representative
*
* Features:
* - EntityGrid integration for contact selection
* - No special entities (You, Unnamed)
* - Immediate assignment on contact selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class ProjectRepresentativeDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Array of available contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected contact and closes the dialog
*/
handleEntitySelected(event: { type: "person" | "project"; data: Contact }) {
const contact = event.data as Contact;
this.emitAssign(contact);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(contact: Contact): Contact {
return contact;
}
}
</script>
<style scoped></style>

View File

@@ -1,66 +0,0 @@
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
entity types. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer">
<router-link :to="navigationRoute" class="block text-center">
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
<h3
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { RouteLocationRaw } from "vue-router";
/**
* ShowAllCard - Displays "Show All" navigation for entity grids
*
* Features:
* - Provides navigation to full entity listings
* - Supports different routes based on entity type
* - Maintains context through query parameters
* - Consistent visual styling with other cards
*/
@Component({ name: "ShowAllCard" })
export default class ShowAllCard extends Vue {
/** Type of entities being shown */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Route name to navigate to */
@Prop({ required: true })
routeName!: string;
/** Query parameters to pass to the route */
@Prop({ default: () => ({}) })
queryParams!: Record<string, string>;
/**
* Computed navigation route with query parameters
*/
get navigationRoute(): RouteLocationRaw {
return {
name: this.routeName,
query: this.queryParams,
};
}
}
</script>
<style scoped>
/* Ensure router-link styling is consistent */
a {
text-decoration: none;
}
a:hover .fa-circle-right {
transform: scale(1.1);
transition: transform 0.2s ease;
}
</style>

View File

@@ -63,23 +63,24 @@ export default class SpecialEntityCard extends Vue {
conflictContext!: string;
/**
* Computed CSS classes for the card container
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseClasses = "block";
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return `${baseClasses} cursor-not-allowed opacity-50`;
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return `${baseClasses} cursor-pointer`;
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the icon
*/
get iconClasses(): string {
const baseClasses = "text-5xl mb-1";
const baseClasses = "text-[2rem]";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
@@ -101,7 +102,7 @@ export default class SpecialEntityCard extends Vue {
*/
get nameClasses(): string {
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
"text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;

View File

@@ -1,772 +0,0 @@
<template>
<section
v-if="notificationsSupported"
id="sectionDailyNotifications"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
aria-labelledby="dailyNotificationsHeading"
>
<h2 id="dailyNotificationsHeading" class="mb-2 font-bold">
Daily Notifications
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about native notifications"
@click.stop="showNativeNotificationInfo"
>
<font-awesome icon="circle-question" aria-hidden="true" />
</button>
</h2>
<div class="flex items-center justify-between">
<div>Daily Notification</div>
<!-- Toggle switch -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="nativeNotificationEnabled"
:aria-label="
nativeNotificationEnabled
? 'Disable daily notifications'
: 'Enable daily notifications'
"
tabindex="0"
@click="toggleNativeNotification"
>
<!-- input -->
<input
:checked="nativeNotificationEnabled"
type="checkbox"
class="sr-only"
tabindex="-1"
readonly
/>
<!-- line -->
<div
class="block bg-slate-500 w-14 h-8 rounded-full transition"
:class="{
'bg-blue-600': nativeNotificationEnabled,
}"
></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
:class="{
'left-7 bg-white': nativeNotificationEnabled,
}"
></div>
</div>
</div>
<!-- Show "Open Settings" button when permissions are denied -->
<div
v-if="
notificationsSupported &&
notificationStatus &&
notificationStatus.permissions.notifications === 'denied'
"
class="mt-2"
>
<button
class="w-full px-3 py-2 bg-blue-600 text-white rounded text-sm font-medium"
@click="openNotificationSettings"
>
Open Settings
</button>
<p class="text-xs text-slate-500 mt-1 text-center">
Enable notifications in Settings > App info > Notifications
</p>
</div>
<!-- Time input section - show when enabled OR when no time is set -->
<div
v-if="nativeNotificationEnabled || !nativeNotificationTimeStorage"
class="mt-2"
>
<div
v-if="nativeNotificationEnabled"
class="flex items-center justify-between mb-2"
>
<span
>Scheduled for:
<span v-if="nativeNotificationTime">{{
nativeNotificationTime
}}</span>
<span v-else class="text-slate-500">Not set</span></span
>
<button
class="text-blue-500 text-sm"
@click="editNativeNotificationTime"
>
{{ showTimeEdit ? "Cancel" : "Edit Time" }}
</button>
</div>
<!-- Time input (shown when editing or when no time is set) -->
<div v-if="showTimeEdit || !nativeNotificationTimeStorage" class="mt-2">
<label class="block text-sm text-slate-600 mb-1">
Notification Time
</label>
<div class="flex items-center gap-2">
<input
v-model="nativeNotificationTimeStorage"
type="time"
class="rounded border border-slate-400 px-2 py-2"
@change="onTimeChange"
/>
<button
v-if="showTimeEdit || nativeNotificationTimeStorage"
class="px-3 py-2 bg-blue-600 text-white rounded"
@click="saveTimeChange"
>
Save
</button>
</div>
<p
v-if="!nativeNotificationTimeStorage"
class="text-xs text-slate-500 mt-1"
>
Set a time before enabling notifications
</p>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="mt-2 text-sm text-slate-500">Loading...</div>
</section>
</template>
<script lang="ts">
/**
* DailyNotificationSection Component
*
* A self-contained component for managing daily notification scheduling
* in AccountViewView. This component handles platform detection, permission
* requests, scheduling, and state management for daily notifications.
*
* Features:
* - Platform capability detection (hides on unsupported platforms)
* - Permission request flow
* - Schedule/cancel notifications
* - Time editing with HTML5 time input
* - Settings persistence
* - Plugin state synchronization
*
* @author Generated for TimeSafari Daily Notification Integration
* @component
*/
import { Component, Vue } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { logger } from "@/utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import type {
NotificationStatus,
PermissionStatus,
} from "@/services/PlatformService";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import type { NotificationIface } from "@/constants/app";
/**
* Convert 24-hour time format ("09:00") to 12-hour display format ("9:00 AM")
*/
function formatTimeForDisplay(time24: string): string {
if (!time24) return "";
const [hours, minutes] = time24.split(":");
const hourNum = parseInt(hours);
const isPM = hourNum >= 12;
const displayHour =
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
return `${displayHour}:${minutes} ${isPM ? "PM" : "AM"}`;
}
@Component({
name: "DailyNotificationSection",
mixins: [PlatformServiceMixin],
})
export default class DailyNotificationSection extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
// Component state
notificationsSupported: boolean = false;
nativeNotificationEnabled: boolean = false;
nativeNotificationTime: string = ""; // Display format: "9:00 AM"
nativeNotificationTimeStorage: string = ""; // Plugin format: "09:00"
nativeNotificationTitle: string = "Daily Update";
nativeNotificationMessage: string = "Your daily notification is ready!";
showTimeEdit: boolean = false;
loading: boolean = false;
notificationStatus: NotificationStatus | null = null;
// Notify helpers
private notify!: ReturnType<typeof createNotifyHelpers>;
async created(): Promise<void> {
this.notify = createNotifyHelpers(this.$notify);
}
/**
* Initialize component state on mount
* Checks platform support and syncs with plugin state
*
* **Token Refresh on Mount:**
* - Refreshes native fetcher configuration to ensure plugin has valid token
* - This handles cases where app was closed for extended periods
* - Token refresh happens automatically without user interaction
*
* **App Resume Listener:**
* - Listens for Capacitor 'resume' event to refresh token when app comes to foreground
* - Ensures plugin always has fresh token for background prefetch operations
* - Cleaned up in `beforeDestroy()` lifecycle hook
*/
async mounted(): Promise<void> {
await this.initializeState();
// Refresh native fetcher configuration on mount
// This ensures plugin has valid token even if app was closed for extended periods
await this.refreshNativeFetcherConfig();
// Listen for app resume events to refresh token when app comes to foreground
// This is part of the proactive token refresh strategy
document.addEventListener("resume", this.handleAppResume);
}
/**
* Cleanup on component destroy
*/
beforeDestroy(): void {
document.removeEventListener("resume", this.handleAppResume);
}
/**
* Handle app resume event - refresh native fetcher configuration
*
* This method is called when the app comes to foreground (via Capacitor 'resume' event).
* It proactively refreshes the JWT token to ensure the plugin has valid authentication
* for background prefetch operations.
*
* **Why refresh on resume?**
* - Tokens expire after 72 hours
* - App may have been closed for extended periods
* - Refreshing ensures plugin has valid token for next prefetch cycle
* - No user interaction required - happens automatically
*
* @see {@link refreshNativeFetcherConfig} For implementation details
*/
async handleAppResume(): Promise<void> {
logger.debug(
"[DailyNotificationSection] App resumed, refreshing native fetcher config",
);
await this.refreshNativeFetcherConfig();
}
/**
* Refresh native fetcher configuration with fresh JWT token
*
* This method ensures the daily notification plugin has a valid authentication token
* for background prefetch operations. It's called proactively to prevent token expiration
* issues during offline periods.
*
* **Refresh Triggers:**
* - Component mount (when notification settings page loads)
* - App resume (when app comes to foreground)
* - Notification enabled (when user enables daily notifications)
*
* **Token Refresh Strategy (Hybrid Approach):**
* - Tokens are valid for 72 hours (see `accessTokenForBackground`)
* - Tokens are refreshed proactively when app is already open
* - If token expires while offline, plugin uses cached content
* - Next time app opens, token is automatically refreshed
*
* **Why This Approach?**
* - No app wake-up required (tokens refresh when app is already open)
* - Works offline (72-hour validity supports extended offline periods)
* - Automatic (no user interaction required)
* - Includes starred plans (fetcher receives user's starred plans for prefetch)
* - Graceful degradation (if refresh fails, cached content still works)
*
* **Error Handling:**
* - Errors are logged but not shown to user (background operation)
* - Returns early if notifications not supported or disabled
* - Returns early if API server not configured
* - Failures don't interrupt user experience
*
* @returns Promise that resolves when refresh completes (or fails silently)
*
* @example
* ```typescript
* // Called automatically on mount/resume
* await this.refreshNativeFetcherConfig();
* ```
*
* @see {@link CapacitorPlatformService.configureNativeFetcher} For token generation
* @see {@link accessTokenForBackground} For 72-hour token generation
*/
async refreshNativeFetcherConfig(): Promise<void> {
try {
const platformService = PlatformServiceFactory.getInstance();
// Early return: Only refresh if notifications are supported and enabled
// This prevents unnecessary work when notifications aren't being used
if (!this.notificationsSupported || !this.nativeNotificationEnabled) {
return;
}
// Get settings for API server and starred plans
// API server tells plugin where to fetch content from
// Starred plans tell plugin which plans to prefetch
const settings = await this.$accountSettings();
const apiServer = settings.apiServer || "";
if (!apiServer) {
logger.warn(
"[DailyNotificationSection] No API server configured, skipping native fetcher refresh",
);
return;
}
// Get starred plans from settings
// These are passed to the plugin so it knows which plans to prefetch
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Configure native fetcher with fresh token
// The jwt parameter is ignored - configureNativeFetcher generates it automatically
// This ensures we always have a fresh token with current expiration time
await platformService.configureNativeFetcher({
apiServer,
jwt: "", // Will be generated automatically by configureNativeFetcher
starredPlanHandleIds,
});
logger.info(
"[DailyNotificationSection] Native fetcher configuration refreshed",
);
} catch (error) {
// Don't show error to user - this is a background operation
// Failures are logged for debugging but don't interrupt user experience
// If refresh fails, plugin will use existing token (if still valid) or cached content
logger.error(
"[DailyNotificationSection] Failed to refresh native fetcher config:",
error,
);
}
}
/**
* Initialize component state
* Checks platform support and syncs with plugin state
*/
async initializeState(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// Check if notifications are supported on this platform
// This also verifies plugin availability (returns null if plugin unavailable)
const status = await platformService.getDailyNotificationStatus();
if (status === null) {
// Notifications not supported or plugin unavailable - don't initialize
this.notificationsSupported = false;
logger.warn(
"[DailyNotificationSection] Notifications not supported or plugin unavailable",
);
return;
}
this.notificationsSupported = true;
this.notificationStatus = status;
// Plugin state is the source of truth
if (status.isScheduled && status.scheduledTime) {
// Plugin has a scheduled notification - sync UI to match
this.nativeNotificationEnabled = true;
this.nativeNotificationTimeStorage = status.scheduledTime;
this.nativeNotificationTime = formatTimeForDisplay(
status.scheduledTime,
);
} else {
// No plugin schedule - UI defaults to disabled
this.nativeNotificationEnabled = false;
this.nativeNotificationTimeStorage = "";
this.nativeNotificationTime = "";
}
} catch (error) {
logger.error("[DailyNotificationSection] Failed to initialize:", error);
this.notificationsSupported = false;
} finally {
this.loading = false;
}
}
/**
* Toggle notification on/off
*/
async toggleNativeNotification(): Promise<void> {
// Prevent multiple simultaneous toggles
if (this.loading) {
logger.warn(
"[DailyNotificationSection] Toggle ignored - operation in progress",
);
return;
}
logger.info(
`[DailyNotificationSection] Toggling notification: ${this.nativeNotificationEnabled} -> ${!this.nativeNotificationEnabled}`,
);
if (this.nativeNotificationEnabled) {
await this.disableNativeNotification();
} else {
await this.enableNativeNotification();
}
}
/**
* Enable daily notification
*/
async enableNativeNotification(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// Check if we have a time set
if (!this.nativeNotificationTimeStorage) {
this.notify.error(
"Please set a notification time first",
TIMEOUTS.SHORT,
);
this.loading = false;
return;
}
// Check permissions first - this also verifies plugin availability
let permissions: PermissionStatus | null;
try {
permissions = await platformService.checkNotificationPermissions();
logger.info(
`[DailyNotificationSection] Permission check result:`,
permissions,
);
} catch (error) {
// Plugin may not be available or there's an error
logger.error(
"[DailyNotificationSection] Failed to check permissions (plugin may be unavailable):",
error,
);
this.notify.error(
"Unable to check notification permissions. The notification plugin may not be installed.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
if (permissions === null) {
// Platform doesn't support notifications or plugin unavailable
logger.warn(
"[DailyNotificationSection] Notifications not supported or plugin unavailable",
);
this.notify.error(
"Notifications are not supported on this platform or the plugin is not installed.",
TIMEOUTS.SHORT,
);
this.nativeNotificationEnabled = false;
return;
}
logger.info(
`[DailyNotificationSection] Permission state: ${permissions.notifications}`,
);
// If permissions are explicitly denied, don't try to request again
// (this prevents the plugin crash when handling denied permissions)
// Android won't show the dialog again if permissions are permanently denied
if (permissions.notifications === "denied") {
logger.warn(
"[DailyNotificationSection] Permissions already denied, directing user to settings",
);
this.notify.error(
"Notification permissions were denied. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
// Only request if permissions are in "prompt" state (not denied, not granted)
// This ensures we only call requestPermissions when Android will actually show a dialog
if (permissions.notifications === "prompt") {
logger.info(
"[DailyNotificationSection] Permission state is 'prompt', requesting permissions...",
);
try {
const result = await platformService.requestNotificationPermissions();
logger.info(
`[DailyNotificationSection] Permission request result:`,
result,
);
if (result === null) {
// Plugin unavailable or request failed
logger.error(
"[DailyNotificationSection] Permission request returned null",
);
this.notify.error(
"Unable to request notification permissions. The plugin may not be available.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
if (!result.notifications) {
// Permission request was denied
logger.warn(
"[DailyNotificationSection] Permission request denied by user",
);
this.notify.error(
"Notification permissions are required. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
// Permissions granted - continue
logger.info(
"[DailyNotificationSection] Permissions granted successfully",
);
} catch (error) {
// Handle permission request errors (including plugin crashes)
logger.error(
"[DailyNotificationSection] Permission request failed:",
error,
);
this.notify.error(
"Unable to request notification permissions. Tap 'Open Settings' to enable them.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
}
} else if (permissions.notifications !== "granted") {
// Unexpected state - shouldn't happen, but handle gracefully
logger.warn(
`[DailyNotificationSection] Unexpected permission state: ${permissions.notifications}`,
);
this.notify.error(
"Unable to determine notification permission status. Tap 'Open Settings' to check.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
return;
} else {
logger.info("[DailyNotificationSection] Permissions already granted");
}
// Permissions are granted - continue with scheduling
// Schedule notification via PlatformService
await platformService.scheduleDailyNotification({
time: this.nativeNotificationTimeStorage, // "09:00" in local time
title: this.nativeNotificationTitle,
body: this.nativeNotificationMessage,
sound: true,
priority: "high",
});
// Update UI state
this.nativeNotificationEnabled = true;
// Refresh native fetcher configuration with fresh token
// This ensures plugin has valid authentication when notifications are first enabled
// Token will be valid for 72 hours, supporting offline prefetch operations
await this.refreshNativeFetcherConfig();
this.notify.success(
"Daily notification scheduled successfully",
TIMEOUTS.SHORT,
);
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to enable notification:",
error,
);
this.notify.error(
"Failed to schedule notification. Please try again.",
TIMEOUTS.LONG,
);
this.nativeNotificationEnabled = false;
} finally {
this.loading = false;
}
}
/**
* Disable daily notification
*/
async disableNativeNotification(): Promise<void> {
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// Cancel notification via PlatformService
await platformService.cancelDailyNotification();
// Update UI state
this.nativeNotificationEnabled = false;
this.nativeNotificationTime = "";
this.nativeNotificationTimeStorage = "";
this.showTimeEdit = false;
this.notify.success("Daily notification disabled", TIMEOUTS.SHORT);
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to disable notification:",
error,
);
this.notify.error(
"Failed to disable notification. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.loading = false;
}
}
/**
* Show/hide time edit input
*/
editNativeNotificationTime(): void {
this.showTimeEdit = !this.showTimeEdit;
}
/**
* Handle time input change
*/
onTimeChange(): void {
// Time is already in nativeNotificationTimeStorage via v-model
// Just update display format
if (this.nativeNotificationTimeStorage) {
this.nativeNotificationTime = formatTimeForDisplay(
this.nativeNotificationTimeStorage,
);
}
}
/**
* Save time change and update notification schedule
*/
async saveTimeChange(): Promise<void> {
if (!this.nativeNotificationTimeStorage) {
this.notify.error("Please select a time", TIMEOUTS.SHORT);
return;
}
// Update display format
this.nativeNotificationTime = formatTimeForDisplay(
this.nativeNotificationTimeStorage,
);
// If notification is enabled, update the schedule
if (this.nativeNotificationEnabled) {
await this.updateNotificationTime(this.nativeNotificationTimeStorage);
} else {
// Just update local state (time preference stored in component)
this.showTimeEdit = false;
this.notify.success("Notification time saved", TIMEOUTS.SHORT);
}
}
/**
* Update notification time
* If notification is enabled, immediately updates the schedule
*/
async updateNotificationTime(newTime: string): Promise<void> {
// newTime is in "HH:mm" format from HTML5 time input
if (!this.nativeNotificationEnabled) {
// If notification is disabled, just update local state
this.nativeNotificationTimeStorage = newTime;
this.nativeNotificationTime = formatTimeForDisplay(newTime);
this.showTimeEdit = false;
return;
}
// Notification is enabled - update the schedule
try {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
// 1. Cancel existing notification
await platformService.cancelDailyNotification();
// 2. Schedule with new time
await platformService.scheduleDailyNotification({
time: newTime, // "09:00" in local time
title: this.nativeNotificationTitle,
body: this.nativeNotificationMessage,
sound: true,
priority: "high",
});
// 3. Update local state
this.nativeNotificationTimeStorage = newTime;
this.nativeNotificationTime = formatTimeForDisplay(newTime);
this.notify.success(
"Notification time updated successfully",
TIMEOUTS.SHORT,
);
this.showTimeEdit = false;
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to update notification time:",
error,
);
this.notify.error(
"Failed to update notification time. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.loading = false;
}
}
/**
* Show info dialog about native notifications
*/
showNativeNotificationInfo(): void {
// TODO: Implement info dialog or navigate to help page
this.notify.info(
"Daily notifications use your device's native notification system. They work even when the app is closed.",
TIMEOUTS.STANDARD,
);
}
/**
* Open app notification settings
*/
async openNotificationSettings(): Promise<void> {
try {
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.openAppNotificationSettings();
if (result === null) {
this.notify.error(
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
TIMEOUTS.LONG,
);
} else {
this.notify.success("Opening notification settings...", TIMEOUTS.SHORT);
}
} catch (error) {
logger.error(
"[DailyNotificationSection] Failed to open notification settings:",
error,
);
this.notify.error(
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
TIMEOUTS.LONG,
);
}
}
}
</script>
<style scoped>
.dot {
transition: left 0.2s ease;
}
</style>

View File

@@ -104,71 +104,6 @@ export const accessToken = async (did?: string) => {
}
};
/**
* Generate a longer-lived access token for background operations
*
* This function creates JWT tokens with extended validity (default 72 hours) for use
* in background prefetch operations. The longer expiration period allows the daily
* notification plugin to work offline for extended periods without requiring the app
* to be in the foreground to refresh tokens.
*
* **Token Refresh Strategy (Hybrid Approach):**
* - Tokens are valid for 72 hours (configurable)
* - Tokens are refreshed proactively when:
* - App comes to foreground (via Capacitor 'resume' event)
* - Component mounts (DailyNotificationSection)
* - Notifications are enabled
* - If token expires while offline, plugin uses cached content
* - Next time app opens, token is automatically refreshed
*
* **Why 72 Hours?**
* - Balances security (read-only prefetch operations) with offline capability
* - Reduces need for app to wake itself for token refresh
* - Allows plugin to work offline for extended periods (e.g., weekend trips)
* - Longer than typical prefetch windows (5 minutes before notification)
*
* **Security Considerations:**
* - Tokens are used only for read-only prefetch operations
* - Tokens are stored securely in plugin's Room database
* - Tokens are refreshed proactively to minimize exposure window
* - No private keys are exposed to native code
*
* @param {string} did - User DID (Decentralized Identifier) for token issuer
* @param {number} expirationMinutes - Optional expiration in minutes (defaults to 72 hours = 4320 minutes)
* @return {Promise<string>} JWT token with extended validity, or empty string if no DID provided
*
* @example
* ```typescript
* // Generate token with default 72-hour expiration
* const token = await accessTokenForBackground("did:ethr:0x...");
*
* // Generate token with custom expiration (24 hours)
* const token24h = await accessTokenForBackground("did:ethr:0x...", 24 * 60);
* ```
*
* @see {@link accessToken} For short-lived tokens (1 minute) for regular API requests
* @see {@link createEndorserJwtForDid} For JWT creation implementation
*/
export const accessTokenForBackground = async (
did?: string,
expirationMinutes?: number,
): Promise<string> => {
if (!did) {
return "";
}
// Use provided expiration or default to 72 hours (4320 minutes)
// This allows background prefetch operations to work offline for extended periods
const expirationSeconds = expirationMinutes
? expirationMinutes * 60
: 72 * 60 * 60; // Default 72 hours
const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + expirationSeconds;
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
return createEndorserJwtForDid(did, tokenPayload);
};
/**
* Extract JWT from various URL formats
* @param jwtUrlText The URL containing the JWT

View File

@@ -32,68 +32,6 @@ export interface PlatformCapabilities {
isNativeApp: boolean;
}
/**
* Permission status for notifications
*/
export interface PermissionStatus {
/** Notification permission status */
notifications: "granted" | "denied" | "prompt";
/** Exact alarms permission status (Android only) */
exactAlarms?: "granted" | "denied" | "prompt";
}
/**
* Result of permission request
*/
export interface PermissionResult {
/** Whether notification permission was granted */
notifications: boolean;
/** Whether exact alarms permission was granted (Android only) */
exactAlarms?: boolean;
}
/**
* Status of scheduled daily notifications
*/
export interface NotificationStatus {
/** Whether a notification is currently scheduled */
isScheduled: boolean;
/** Scheduled time in "HH:mm" format (24-hour) */
scheduledTime?: string;
/** Last time the notification was triggered (ISO string) */
lastTriggered?: string;
/** Current permission status */
permissions: PermissionStatus;
}
/**
* Options for scheduling a daily notification
*/
export interface ScheduleOptions {
/** Time in "HH:mm" format (24-hour) in local time */
time: string;
/** Notification title */
title: string;
/** Notification body text */
body: string;
/** Whether to play sound (default: true) */
sound?: boolean;
/** Notification priority */
priority?: "high" | "normal" | "low";
}
/**
* Configuration for native fetcher background operations
*/
export interface NativeFetcherConfig {
/** API server URL */
apiServer: string;
/** JWT token for authentication */
jwt: string;
/** Array of starred plan handle IDs */
starredPlanHandleIds: string[];
}
/**
* Platform-agnostic interface for handling platform-specific operations.
* Provides a common API for file system operations, camera interactions,
@@ -271,58 +209,6 @@ export interface PlatformService {
*/
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
// Daily notification operations
/**
* Get the status of scheduled daily notifications
* @returns Promise resolving to notification status, or null if not supported
*/
getDailyNotificationStatus(): Promise<NotificationStatus | null>;
/**
* Check notification permissions
* @returns Promise resolving to permission status, or null if not supported
*/
checkNotificationPermissions(): Promise<PermissionStatus | null>;
/**
* Request notification permissions
* @returns Promise resolving to permission result, or null if not supported
*/
requestNotificationPermissions(): Promise<PermissionResult | null>;
/**
* Schedule a daily notification
* @param options - Notification scheduling options
* @returns Promise that resolves when scheduled, or rejects if not supported
*/
scheduleDailyNotification(options: ScheduleOptions): Promise<void>;
/**
* Cancel scheduled daily notification
* @returns Promise that resolves when cancelled, or rejects if not supported
*/
cancelDailyNotification(): Promise<void>;
/**
* Configure native fetcher for background operations
* @param config - Native fetcher configuration
* @returns Promise that resolves when configured, or null if not supported
*/
configureNativeFetcher(config: NativeFetcherConfig): Promise<void | null>;
/**
* Update starred plans for background fetcher
* @param plans - Starred plan IDs
* @returns Promise that resolves when updated, or null if not supported
*/
updateStarredPlans(plans: { planIds: string[] }): Promise<void | null>;
/**
* Open the app's notification settings in the system settings
* @returns Promise that resolves when the settings page is opened, or null if not supported
*/
openAppNotificationSettings(): Promise<void | null>;
// --- PWA/Web-only methods (optional, only implemented on web) ---
/**
* Registers the service worker for PWA support (web only)

View File

@@ -20,11 +20,6 @@ import {
ImageResult,
PlatformService,
PlatformCapabilities,
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { BaseDatabaseService } from "./BaseDatabaseService";
@@ -1338,445 +1333,6 @@ export class CapacitorPlatformService
// --- PWA/Web-only methods (no-op for Capacitor) ---
public registerServiceWorker(): void {}
// Daily notification operations
/**
* Get the status of scheduled daily notifications
* @see PlatformService.getDailyNotificationStatus
*/
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
try {
// Dynamic import to avoid build issues if plugin unavailable
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
const pluginStatus = await DailyNotification.getNotificationStatus();
// Get permissions separately
const permissions = await DailyNotification.checkPermissions();
// Map plugin PermissionState to our PermissionStatus format
const notificationsPermission = permissions.notifications;
let notifications: "granted" | "denied" | "prompt";
if (notificationsPermission === "granted") {
notifications = "granted";
} else if (notificationsPermission === "denied") {
notifications = "denied";
} else {
notifications = "prompt";
}
// Handle lastNotificationTime which can be a Promise<number>
let lastTriggered: string | undefined;
const lastNotificationTime = pluginStatus.lastNotificationTime;
if (lastNotificationTime) {
const timeValue = await Promise.resolve(lastNotificationTime);
if (typeof timeValue === "number") {
lastTriggered = new Date(timeValue).toISOString();
}
}
return {
isScheduled: pluginStatus.isScheduled ?? false,
scheduledTime: pluginStatus.settings?.time,
lastTriggered,
permissions: {
notifications,
exactAlarms: undefined, // Plugin doesn't expose this in status
},
};
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to get notification status:",
error,
);
return null;
}
}
/**
* Check notification permissions
* @see PlatformService.checkNotificationPermissions
*/
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
const permissions = await DailyNotification.checkPermissions();
// Log the raw permission state for debugging
logger.info(
`[CapacitorPlatformService] Raw permission state from plugin:`,
permissions,
);
// Map plugin PermissionState to our PermissionStatus format
const notificationsPermission = permissions.notifications;
let notifications: "granted" | "denied" | "prompt";
// Handle all possible PermissionState values
if (notificationsPermission === "granted") {
notifications = "granted";
} else if (
notificationsPermission === "denied" ||
notificationsPermission === "ephemeral"
) {
notifications = "denied";
} else {
// Treat "prompt", "prompt-with-rationale", "unknown", "provisional" as "prompt"
// This allows Android to show the permission dialog
notifications = "prompt";
}
logger.info(
`[CapacitorPlatformService] Mapped permission state: ${notifications} (from ${notificationsPermission})`,
);
return {
notifications,
exactAlarms: undefined, // Plugin doesn't expose this directly
};
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to check permissions:",
error,
);
return null;
}
}
/**
* Request notification permissions
* @see PlatformService.requestNotificationPermissions
*/
async requestNotificationPermissions(): Promise<PermissionResult | null> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
logger.info(
`[CapacitorPlatformService] Requesting notification permissions...`,
);
const result = await DailyNotification.requestPermissions();
logger.info(
`[CapacitorPlatformService] Permission request result:`,
result,
);
// Map plugin PermissionState to boolean
const notificationsGranted = result.notifications === "granted";
logger.info(
`[CapacitorPlatformService] Mapped permission result: ${notificationsGranted} (from ${result.notifications})`,
);
return {
notifications: notificationsGranted,
exactAlarms: undefined, // Plugin doesn't expose this directly
};
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to request permissions:",
error,
);
return null;
}
}
/**
* Schedule a daily notification
* @see PlatformService.scheduleDailyNotification
*/
async scheduleDailyNotification(options: ScheduleOptions): Promise<void> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
await DailyNotification.scheduleDailyNotification({
time: options.time,
title: options.title,
body: options.body,
sound: options.sound ?? true,
priority: options.priority ?? "high",
});
logger.info(
`[CapacitorPlatformService] Scheduled daily notification for ${options.time}`,
);
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to schedule notification:",
error,
);
throw error;
}
}
/**
* Cancel scheduled daily notification
* @see PlatformService.cancelDailyNotification
*/
async cancelDailyNotification(): Promise<void> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
await DailyNotification.cancelAllNotifications();
logger.info("[CapacitorPlatformService] Cancelled daily notification");
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to cancel notification:",
error,
);
throw error;
}
}
/**
* Configure native fetcher for background operations
*
* This method configures the daily notification plugin's native content fetcher
* with authentication credentials for background prefetch operations. It automatically
* retrieves the active DID from the database and generates a fresh JWT token with
* 72-hour expiration.
*
* **Authentication Flow:**
* 1. Retrieves active DID from `active_identity` table (single source of truth)
* 2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()`
* 3. Configures plugin with API server URL, active DID, and JWT token
* 4. Plugin stores token in its Room database for background workers
*
* **Token Management:**
* - Tokens are valid for 72 hours (4320 minutes)
* - Tokens are refreshed proactively when app comes to foreground
* - If token expires while offline, plugin uses cached content
* - Token refresh happens automatically via `DailyNotificationSection.refreshNativeFetcherConfig()`
*
* **Offline-First Design:**
* - 72-hour validity supports extended offline periods
* - Plugin can prefetch content when online and use cached content when offline
* - No app wake-up required for token refresh (happens when app is already open)
*
* **Error Handling:**
* - Returns `null` if active DID not found (no user logged in)
* - Returns `null` if JWT generation fails
* - Logs errors but doesn't throw (allows graceful degradation)
*
* @param config - Native fetcher configuration
* @param config.apiServer - API server URL (optional, uses default if not provided)
* @param config.jwt - JWT token (ignored, generated automatically)
* @param config.starredPlanHandleIds - Array of starred plan handle IDs for prefetch
* @returns Promise that resolves when configured, or `null` if configuration failed
*
* @example
* ```typescript
* await platformService.configureNativeFetcher({
* apiServer: "https://api.endorser.ch",
* jwt: "", // Generated automatically
* starredPlanHandleIds: ["plan-123", "plan-456"]
* });
* ```
*
* @see {@link accessTokenForBackground} For JWT token generation
* @see {@link DailyNotificationSection.refreshNativeFetcherConfig} For proactive token refresh
* @see PlatformService.configureNativeFetcher
*/
async configureNativeFetcher(
config: NativeFetcherConfig,
): Promise<void | null> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
// Step 1: Get activeDid from database (single source of truth)
// This ensures we're using the correct user identity for authentication
const activeIdentity = await this.getActiveIdentity();
const activeDid = activeIdentity.activeDid;
if (!activeDid) {
logger.warn(
"[CapacitorPlatformService] No activeDid found, cannot configure native fetcher",
);
return null;
}
// Step 2: Generate JWT token for background operations
// Use 72-hour expiration for offline-first prefetch operations
// This allows the plugin to work offline for extended periods
const { accessTokenForBackground } = await import(
"../../libs/crypto/index"
);
// Use 72 hours (4320 minutes) for background prefetch tokens
// This is longer than passkey expiration to support offline scenarios
const expirationMinutes = 72 * 60; // 72 hours
const jwtToken = await accessTokenForBackground(
activeDid,
expirationMinutes,
);
if (!jwtToken) {
logger.error("[CapacitorPlatformService] Failed to generate JWT token");
return null;
}
// Step 3: Get API server from config or use default
// This ensures the plugin knows where to fetch content from
const apiServer =
config.apiServer ||
(await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER;
// Step 4: Configure plugin with credentials
// Plugin stores these in its Room database for background workers
await DailyNotification.configureNativeFetcher({
apiBaseUrl: apiServer,
activeDid,
jwtToken,
});
logger.info("[CapacitorPlatformService] Configured native fetcher", {
activeDid,
apiServer,
tokenExpirationHours: 72,
tokenExpirationMinutes: expirationMinutes,
});
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to configure native fetcher:",
error,
);
return null;
}
}
/**
* Update starred plans for background fetcher
* @see PlatformService.updateStarredPlans
*/
async updateStarredPlans(plans: { planIds: string[] }): Promise<void | null> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
await DailyNotification.updateStarredPlans({
planIds: plans.planIds,
});
logger.info(
`[CapacitorPlatformService] Updated starred plans: ${plans.planIds.length} plans`,
);
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to update starred plans:",
error,
);
return null;
}
}
/**
* Open the app's notification settings in the system settings
* @see PlatformService.openAppNotificationSettings
*/
async openAppNotificationSettings(): Promise<void | null> {
try {
const platform = Capacitor.getPlatform();
if (platform === "android") {
// Android: Open app details settings page
// From there, users can navigate to "Notifications" section
// This is more reliable than trying to open notification settings directly
const packageName = "app.timesafari.app"; // Full application ID from build.gradle
// Use APPLICATION_DETAILS_SETTINGS which opens the app's settings page
// Users can then navigate to "Notifications" section
// Try multiple URL formats to ensure compatibility
const intentUrl1 = `intent:#Intent;action=android.settings.APPLICATION_DETAILS_SETTINGS;data=package:${packageName};end`;
const intentUrl2 = `intent://settings/app_detail?package=${packageName}#Intent;scheme=android-app;end`;
logger.info(
`[CapacitorPlatformService] Opening Android app settings for ${packageName}`,
);
// Log current permission state before opening settings
try {
const currentPerms = await this.checkNotificationPermissions();
logger.info(
`[CapacitorPlatformService] Current permission state before opening settings:`,
currentPerms,
);
} catch (e) {
logger.warn(
`[CapacitorPlatformService] Could not check permissions before opening settings:`,
e,
);
}
// Try multiple approaches to ensure it works
try {
// Method 1: Direct window.location.href (most reliable)
window.location.href = intentUrl1;
// Method 2: Fallback with window.open
setTimeout(() => {
try {
window.open(intentUrl1, "_blank");
} catch (e) {
logger.warn(
"[CapacitorPlatformService] window.open fallback failed:",
e,
);
}
}, 100);
// Method 3: Alternative format
setTimeout(() => {
try {
window.location.href = intentUrl2;
} catch (e) {
logger.warn(
"[CapacitorPlatformService] Alternative format failed:",
e,
);
}
}, 200);
} catch (e) {
logger.error(
"[CapacitorPlatformService] Failed to open intent URL:",
e,
);
}
} else if (platform === "ios") {
// iOS: Use app settings URL scheme
const settingsUrl = `app-settings:`;
window.location.href = settingsUrl;
logger.info("[CapacitorPlatformService] Opening iOS app settings");
} else {
logger.warn(
`[CapacitorPlatformService] Cannot open settings on platform: ${platform}`,
);
return null;
}
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to open app notification settings:",
error,
);
return null;
}
}
// Database utility methods - inherited from BaseDatabaseService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,

View File

@@ -22,13 +22,6 @@
import { CapacitorPlatformService } from "./CapacitorPlatformService";
import { logger } from "../../utils/logger";
import {
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService";
/**
* Electron-specific platform service implementation.
@@ -173,88 +166,4 @@ export class ElectronPlatformService extends CapacitorPlatformService {
// --- PWA/Web-only methods (no-op for Electron) ---
public registerServiceWorker(): void {}
// Daily notification operations
// Override CapacitorPlatformService methods to return null/throw errors
// since Electron doesn't support native daily notifications
/**
* Get the status of scheduled daily notifications
* @see PlatformService.getDailyNotificationStatus
* @returns null - notifications not supported on Electron platform
*/
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
return null;
}
/**
* Check notification permissions
* @see PlatformService.checkNotificationPermissions
* @returns null - notifications not supported on Electron platform
*/
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
return null;
}
/**
* Request notification permissions
* @see PlatformService.requestNotificationPermissions
* @returns null - notifications not supported on Electron platform
*/
async requestNotificationPermissions(): Promise<PermissionResult | null> {
return null;
}
/**
* Schedule a daily notification
* @see PlatformService.scheduleDailyNotification
* @throws Error - notifications not supported on Electron platform
*/
async scheduleDailyNotification(_options: ScheduleOptions): Promise<void> {
throw new Error(
"Daily notifications are not supported on Electron platform",
);
}
/**
* Cancel scheduled daily notification
* @see PlatformService.cancelDailyNotification
* @throws Error - notifications not supported on Electron platform
*/
async cancelDailyNotification(): Promise<void> {
throw new Error(
"Daily notifications are not supported on Electron platform",
);
}
/**
* Configure native fetcher for background operations
* @see PlatformService.configureNativeFetcher
* @returns null - native fetcher not supported on Electron platform
*/
async configureNativeFetcher(
_config: NativeFetcherConfig,
): Promise<void | null> {
return null;
}
/**
* Update starred plans for background fetcher
* @see PlatformService.updateStarredPlans
* @returns null - native fetcher not supported on Electron platform
*/
async updateStarredPlans(_plans: {
planIds: string[];
}): Promise<void | null> {
return null;
}
/**
* Open the app's notification settings in the system settings
* @see PlatformService.openAppNotificationSettings
* @returns null - not supported on Electron platform
*/
async openAppNotificationSettings(): Promise<void | null> {
return null;
}
}

View File

@@ -2,11 +2,6 @@ import {
ImageResult,
PlatformService,
PlatformCapabilities,
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
@@ -682,81 +677,4 @@ export class WebPlatformService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
// Daily notification operations
/**
* Get the status of scheduled daily notifications
* @see PlatformService.getDailyNotificationStatus
* @returns null - notifications not supported on web platform
*/
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
return null;
}
/**
* Check notification permissions
* @see PlatformService.checkNotificationPermissions
* @returns null - notifications not supported on web platform
*/
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
return null;
}
/**
* Request notification permissions
* @see PlatformService.requestNotificationPermissions
* @returns null - notifications not supported on web platform
*/
async requestNotificationPermissions(): Promise<PermissionResult | null> {
return null;
}
/**
* Schedule a daily notification
* @see PlatformService.scheduleDailyNotification
* @throws Error - notifications not supported on web platform
*/
async scheduleDailyNotification(_options: ScheduleOptions): Promise<void> {
throw new Error("Daily notifications are not supported on web platform");
}
/**
* Cancel scheduled daily notification
* @see PlatformService.cancelDailyNotification
* @throws Error - notifications not supported on web platform
*/
async cancelDailyNotification(): Promise<void> {
throw new Error("Daily notifications are not supported on web platform");
}
/**
* Configure native fetcher for background operations
* @see PlatformService.configureNativeFetcher
* @returns null - native fetcher not supported on web platform
*/
async configureNativeFetcher(
_config: NativeFetcherConfig,
): Promise<void | null> {
return null;
}
/**
* Update starred plans for background fetcher
* @see PlatformService.updateStarredPlans
* @returns null - native fetcher not supported on web platform
*/
async updateStarredPlans(_plans: {
planIds: string[];
}): Promise<void | null> {
return null;
}
/**
* Open the app's notification settings in the system settings
* @see PlatformService.openAppNotificationSettings
* @returns null - not supported on web platform
*/
async openAppNotificationSettings(): Promise<void | null> {
return null;
}
}

View File

@@ -19,7 +19,6 @@
<EntityGrid
entity-type="people"
:entities="people"
:max-items="5"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -39,7 +38,6 @@
<EntityGrid
entity-type="projects"
:entities="projects"
:max-items="3"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -152,11 +150,8 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = (
entities: Contact[],
_entityType: string,
maxItems: number,
): Contact[] => {
return entities
.filter((person) => person.profileImageUrl)
.slice(0, maxItems);
return entities.filter((person) => person.profileImageUrl);
};
/**
@@ -165,7 +160,6 @@ export default class EntityGridFunctionPropTest extends Vue {
customProjectsFunction = (
entities: PlanData[],
_entityType: string,
_maxItems: number,
): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
};
@@ -200,16 +194,16 @@ export default class EntityGridFunctionPropTest extends Vue {
*/
get displayedPeopleCount(): number {
if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people", 5).length;
return this.customPeopleFunction(this.people, "people").length;
}
return Math.min(5, this.people.length);
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
}
get displayedProjectsCount(): number {
if (this.useCustomFunction) {
return this.customProjectsFunction(this.projects, "projects", 3).length;
return this.customProjectsFunction(this.projects, "projects").length;
}
return Math.min(7, this.projects.length);
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
}
}
</script>

View File

@@ -970,6 +970,20 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts);
},
/**
* Load all contacts sorted by when they were added (by ID)
* Always fetches fresh data from database for consistency
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
*/
async $contactsByDateAdded(): Promise<Contact[]> {
const rawContacts = (await this.$query(
"SELECT * FROM contacts ORDER BY id DESC",
)) as ContactMaybeWithJsonStrings[];
return this.$normalizeContacts(rawContacts);
},
/**
* Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total number of contacts
@@ -2057,6 +2071,7 @@ declare module "@vue/runtime-core" {
// Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;

View File

@@ -161,9 +161,6 @@
</section>
<PushNotificationPermission ref="pushNotificationPermission" />
<!-- Daily Notifications (Native) -->
<DailyNotificationSection />
<!-- User Profile -->
<section
v-if="isRegistered"
@@ -793,7 +790,6 @@ import IdentitySection from "@/components/IdentitySection.vue";
import RegistrationNotice from "@/components/RegistrationNotice.vue";
import LocationSearchSection from "@/components/LocationSearchSection.vue";
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
import DailyNotificationSection from "@/components/notifications/DailyNotificationSection.vue";
import {
AppString,
DEFAULT_IMAGE_API_SERVER,
@@ -862,7 +858,6 @@ interface UserNameDialogRef {
RegistrationNotice,
LocationSearchSection,
UsageLimitsSection,
DailyNotificationSection,
},
mixins: [PlatformServiceMixin],
})

View File

@@ -60,12 +60,60 @@
</div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<input
v-model="agentDid"
type="text"
placeholder="Other Authorized Representative"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
<!-- Authorized Representative Selection -->
<div class="w-full flex items-stretch my-4">
<div
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
@click="openRepresentativeDialog"
>
<div>
<EntityIcon
v-if="selectedRepresentative"
:contact="selectedRepresentative"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome v-else icon="user" class="text-slate-400" />
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedRepresentative,
'text-slate-400': !selectedRepresentative,
}"
class="truncate"
>
{{
selectedRepresentative
? selectedRepresentative.name || AppString.NO_CONTACT_NAME
: "Assign Authorized Representative…"
}}
</div>
<div
v-if="selectedRepresentative"
class="text-xs text-slate-500 truncate"
>
{{ agentDid }}
</div>
</div>
</div>
<button
v-if="selectedRepresentative"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetRepresentative"
>
<font-awesome icon="trash-can" />
</button>
</div>
<ProjectRepresentativeDialog
ref="representativeDialog"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:notify="$notify"
@assign="handleRepresentativeAssigned"
/>
<div class="mb-4">
<p v-if="shouldShowOwnershipWarning">
<span class="text-red-500">Beware!</span>
@@ -232,9 +280,12 @@ import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { LeafletMouseEvent } from "leaflet";
import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import {
AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
@@ -268,6 +319,7 @@ import {
retrieveAccountCount,
retrieveFullyDecryptedAccount,
} from "../libs/util";
import { Contact } from "../db/tables/contacts";
import {
EventTemplate,
@@ -323,7 +375,15 @@ import { logger } from "../utils/logger";
*/
@Component({
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
components: {
EntityIcon,
ImageMethodDialog,
ProjectRepresentativeDialog,
LMap,
LMarker,
LTileLayer,
QuickNav,
},
mixins: [PlatformServiceMixin],
})
export default class NewEditProjectView extends Vue {
@@ -334,6 +394,9 @@ export default class NewEditProjectView extends Vue {
// Notification helpers
private notify!: ReturnType<typeof createNotifyHelpers>;
// Constants
AppString = AppString;
/**
* Display error notification to user
* Provides consistent error messaging with 5-second timeout
@@ -346,6 +409,8 @@ export default class NewEditProjectView extends Vue {
// Component state properties
activeDid = "";
agentDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
apiServer = "";
endDateInput?: string;
endTimeInput?: string;
@@ -392,16 +457,24 @@ export default class NewEditProjectView extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
// Get all user's DIDs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
// Load contacts sorted by date added (newest first) for consistent "Recently Added" display
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId = (this.$route.query["projectId"] as string) || "";
if (this.projectId) {
if (this.isSavedProject()) {
if (this.numAccounts === 0) {
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
} else {
this.loadProject(this.activeDid);
this.loadProject(this.activeDid, this.projectId);
}
}
}
@@ -411,11 +484,9 @@ export default class NewEditProjectView extends Vue {
* Retrieves project information from the API and populates form fields
* @param userDid - User's decentralized identifier
*/
async loadProject(userDid: string) {
async loadProject(userDid: string, projectId: string) {
const url =
this.apiServer +
"/api/claim/byHandle/" +
encodeURIComponent(this.projectId);
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
const headers = await getHeaders(userDid);
try {
@@ -432,6 +503,12 @@ export default class NewEditProjectView extends Vue {
}
if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier;
if (this.activeDid !== this.projectIssuerDid) {
this.agentDid = this.projectIssuerDid;
this.notify.warning(
"You were previously the agent, so the agent has been set to the previous owner. You can change it.",
);
}
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
@@ -536,7 +613,7 @@ export default class NewEditProjectView extends Vue {
private async saveProject() {
// Make a claim
const vcClaim: PlanActionClaim = this.fullClaim;
if (this.projectId) {
if (this.isSavedProject()) {
vcClaim.lastClaimId = this.lastClaimJwtId;
}
if (this.agentDid) {
@@ -870,6 +947,10 @@ export default class NewEditProjectView extends Vue {
this.longitude = event.latlng.lng;
}
private isSavedProject(): boolean {
return !!this.projectId;
}
/**
* Computed property for character count display
* Shows current description length and maximum character limit
@@ -885,6 +966,7 @@ export default class NewEditProjectView extends Vue {
*/
get shouldShowOwnershipWarning(): boolean {
return (
this.isSavedProject() &&
this.activeDid !== this.projectIssuerDid &&
this.agentDid !== this.projectIssuerDid
);
@@ -961,5 +1043,37 @@ export default class NewEditProjectView extends Vue {
get shouldShowSpinner(): boolean {
return !this.isHiddenSpinner;
}
/**
* Computed property for selected representative contact
* Derives the contact from agentDid by finding it in allContacts
*/
get selectedRepresentative(): Contact | null {
if (!this.agentDid) {
return null;
}
return this.allContacts.find((c) => c.did === this.agentDid) || null;
}
/**
* Open the representative selection dialog
*/
openRepresentativeDialog(): void {
(this.$refs.representativeDialog as ProjectRepresentativeDialog).open();
}
/**
* Handle representative assignment from dialog
*/
handleRepresentativeAssigned(contact: Contact): void {
this.agentDid = contact.did;
}
/**
* Unset the representative and revert to initial state
*/
unsetRepresentative(): void {
this.agentDid = "";
}
}
</script>

View File

@@ -186,16 +186,59 @@
<div>
<label
for="projectLink"
class="block text-sm font-medium text-gray-700"
class="block text-sm font-medium text-gray-700 mb-1"
>Project Link</label
>
<input
id="projectLink"
v-model="newOrUpdatedMeetingInputs.projectLink"
type="text"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Project ID"
/>
<div class="w-full flex items-stretch">
<div
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
@click="openProjectLinkDialog"
>
<div>
<ProjectIcon
v-if="selectedProject"
:entity-id="selectedProject.handleId"
:icon-size="30"
:image-url="selectedProject.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="folder-open"
class="text-slate-400"
/>
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedProject,
'text-slate-400': !selectedProject,
}"
class="truncate"
>
{{
selectedProject
? selectedProject.name || "Unnamed Project"
: "Select Project…"
}}
</div>
<div
v-if="selectedProject"
class="text-xs text-slate-500 truncate"
>
<font-awesome icon="user" class="text-slate-400" />
{{ selectedProjectIssuerName }}
</div>
</div>
</div>
<button
v-if="selectedProject"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetProjectLink"
>
<font-awesome icon="trash-can" />
</button>
</div>
</div>
<button
@@ -224,6 +267,17 @@
</form>
</div>
<MeetingProjectDialog
ref="meetingProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleProjectLinkAssigned"
@open="handleDialogOpen"
@close="handleDialogClose"
/>
<!-- Members Section -->
<div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
@@ -254,6 +308,7 @@
</ul>
<MembersList
ref="membersList"
:password="currentMeeting.password || ''"
:show-organizer-tools="true"
class="mt-4"
@@ -292,10 +347,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
didInfo,
} from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -309,6 +367,8 @@ import {
NOTIFY_MEETING_DELETED,
NOTIFY_MEETING_LINK_COPIED,
} from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
interface ServerMeeting {
groupId: number; // from the server
name: string; // to & from the server
@@ -331,6 +391,8 @@ interface MeetingSetupInputs {
QuickNav,
TopMessage,
MembersList,
MeetingProjectDialog,
ProjectIcon,
},
mixins: [PlatformServiceMixin],
})
@@ -354,6 +416,9 @@ export default class OnboardMeetingView extends Vue {
isRegistered = false;
showDeleteConfirm = false;
fullName = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
selectedProjectData: PlanData | null = null;
get minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
@@ -370,7 +435,17 @@ export default class OnboardMeetingView extends Vue {
this.fullName = settings?.firstName || "";
this.isRegistered = !!settings?.isRegistered;
// Load contacts and DIDs for dialog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
await this.fetchCurrentMeeting();
// Ensure selected project is loaded if projectLink exists
await this.ensureSelectedProjectLoaded();
this.isLoading = false;
}
@@ -442,6 +517,54 @@ export default class OnboardMeetingView extends Vue {
}
}
/**
* Ensure the selected project is loaded if projectLink exists
*/
async ensureSelectedProjectLoaded(): Promise<void> {
const projectLink =
this.currentMeeting?.projectLink ||
this.newOrUpdatedMeetingInputs?.projectLink;
if (!projectLink) {
this.selectedProjectData = null;
return;
}
await this.fetchProjectByHandleId(projectLink);
}
/**
* Fetch a single project by handleId
* @param handleId - The project handleId to fetch
*/
async fetchProjectByHandleId(handleId: string): Promise<void> {
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
const project = resp.data.data[0];
this.selectedProjectData = {
name: project.name,
description: project.description,
image: project.image,
handleId: project.handleId,
issuerDid: project.issuerDid,
rowId: project.rowId,
};
} else {
this.selectedProjectData = null;
}
} catch (error) {
this.$logAndConsole(
"Error fetching project by handleId: " + errorStringForLog(error),
true,
);
this.selectedProjectData = null;
}
}
async createMeeting() {
this.isLoading = true;
@@ -576,7 +699,7 @@ export default class OnboardMeetingView extends Vue {
}
}
startEditing() {
async startEditing() {
// Populate form with existing meeting data
if (this.currentMeeting) {
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
@@ -587,6 +710,10 @@ export default class OnboardMeetingView extends Vue {
password: this.currentMeeting.password || "",
projectLink: this.currentMeeting.projectLink || "",
};
// Ensure selected project is loaded if projectLink exists
if (this.currentMeeting.projectLink) {
await this.ensureSelectedProjectLoaded();
}
} else {
this.$logError(
"There is no current meeting to edit. We should never get here.",
@@ -594,9 +721,15 @@ export default class OnboardMeetingView extends Vue {
}
}
cancelEditing() {
async cancelEditing() {
// Reset form data
this.newOrUpdatedMeetingInputs = null;
// Restore selected project from currentMeeting if it exists
if (this.currentMeeting?.projectLink) {
await this.ensureSelectedProjectLoaded();
} else {
this.selectedProjectData = null;
}
}
async updateMeeting() {
@@ -710,5 +843,78 @@ export default class OnboardMeetingView extends Vue {
this.notify.error("Failed to copy meeting link to clipboard.");
}
}
/**
* Computed property for selected project
* Returns the separately stored selected project data
*/
get selectedProject(): PlanData | null {
return this.selectedProjectData;
}
/**
* Computed property for selected project issuer display name
* Uses didInfo to format the issuer name similar to ProjectCard
*/
get selectedProjectIssuerName(): string {
if (!this.selectedProject) {
return "";
}
return didInfo(
this.selectedProject.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Open the project link selection dialog
*/
openProjectLinkDialog(): void {
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
}
/**
* Handle project assignment from dialog
*/
handleProjectLinkAssigned(project: PlanData): void {
// Store the selected project directly
this.selectedProjectData = project;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
}
}
/**
* Unset the project link and revert to initial state
*/
unsetProjectLink(): void {
this.selectedProjectData = null;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = "";
}
}
/**
* Handle dialog open event - stop auto-refresh in MembersList
*/
handleDialogOpen(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.stopAutoRefresh();
}
}
/**
* Handle dialog close event - start auto-refresh in MembersList
*/
handleDialogClose(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.startAutoRefresh();
}
}
}
</script>

View File

@@ -1,23 +1,4 @@
import { defineConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
export default defineConfig(async () => {
const baseConfig = await createBuildConfig('capacitor');
return {
...baseConfig,
build: {
...baseConfig.build,
rollupOptions: {
...baseConfig.build?.rollupOptions,
// Externalize Capacitor plugins that are bundled natively
external: [
"@timesafari/daily-notification-plugin"
],
output: {
...baseConfig.build?.rollupOptions?.output,
}
}
}
};
});
export default defineConfig(async () => createBuildConfig('capacitor'));