Compare commits

..

61 Commits

Author SHA1 Message Date
Matthew Raymer
831532739c feat: implement 72-hour JWT token refresh for daily notification plugin
- Add accessTokenForBackground() with 72-hour default expiration
  - Supports offline-first prefetch operations
  - Balances security with offline capability

- Implement proactive token refresh strategy
  - Refresh on component mount (DailyNotificationSection)
  - Refresh on app resume (Capacitor 'resume' event)
  - Refresh when notifications are enabled
  - Automatic refresh without user interaction

- Update CapacitorPlatformService.configureNativeFetcher()
  - Automatically retrieves activeDid from database
  - Generates 72-hour JWT tokens for background operations
  - Includes starred plans in configuration

- Add BroadcastReceivers to AndroidManifest.xml
  - DailyNotificationReceiver for scheduled notifications
  - BootReceiver for rescheduling after device reboot

- Add comprehensive documentation
  - JSDoc comments for all token-related functions
  - Inline comments explaining refresh strategy
  - Documentation section on authentication & token management

Benefits:
- No app wake-up required (refresh when app already open)
- Works offline (72-hour validity supports extended periods)
- Automatic (no user interaction required)
- Graceful degradation (uses cached content if refresh fails)
2025-11-06 12:44:06 +00:00
Matthew Raymer
5f17f6cb4e feat(notifications): integrate daily notification plugin into AccountViewView
- Add notification methods to PlatformService interface
- Implement notification support in CapacitorPlatformService with plugin integration
- Add stub implementations in WebPlatformService and ElectronPlatformService
- Add nativeNotificationTime, nativeNotificationTitle, and nativeNotificationMessage fields to Settings interface
- Create DailyNotificationSection component for AccountViewView integration
- Add Android manifest permissions (POST_NOTIFICATIONS, SCHEDULE_EXACT_ALARM, RECEIVE_BOOT_COMPLETED)
- Register daily-notification-plugin in capacitor.plugins.json
- Integrate DailyNotificationSection into AccountViewView

Features:
- Platform capability detection (hides on unsupported platforms)
- Permission request flow with fallback to settings
- Schedule/cancel notifications
- Time editing with HTML5 time input
- Settings persistence
- Plugin state synchronization on app load

NOTE: Currently storing notification schedule in SQLite database, but plugin
was designed to store schedule internally. Consider migrating to plugin's
internal storage to avoid database initialization issues.
2025-11-05 10:37:02 +00:00
Matthew Raymer
5def44c349 fix(db): remove inline comments from migration 006 SQL
Migration 006 was failing during database initialization because the SQLite
plugin splits SQL statements on semicolons, and inline comments after
semicolons were being treated as separate statements. When the last statement
was comment-only (e.g., '-- Notification body text'), it caused an error.

Fixed by removing all inline comments from the migration SQL. The comments
are already documented in the TypeScript code, so they're not needed in the
SQL itself.

NOTE: We're experiencing database initialization problems with storing
notification schedule data. The daily notification plugin was originally
designed to store the schedule internally, which would be a better approach
than storing it in our SQLite database. We should consider migrating to
using the plugin's internal storage instead of adding these columns to the
settings table.
2025-11-05 10:36:00 +00:00
Matthew Raymer
45eff4a9ac docs: add plugin state sync, time update logic, and component extraction
- Update initialization to sync with plugin state on mount (checks for pre-existing schedules)
- Add updateNotificationTime() method to update schedule when time changes (cancel old, schedule new)
- Extract DailyNotificationSection into dedicated component using vue-facing-decorator
- Update component architecture to show DailyNotificationSection.vue structure
- Update Phase 2 tasks to reflect component creation and AccountViewView integration
- Add acceptance criteria for plugin state sync and time update functionality
- Update verification checklist with new requirements
2025-11-05 06:34:53 +00:00
Matthew Raymer
ae5f1a33a7 docs: add checkboxes to all actionable items in integration plan
- Add checkboxes to Phase 1, 2, 3 task sub-items
- Add checkboxes to Milestone success criteria
- Add checkboxes to Testing Strategy test items
- Add checkboxes to Risk Mitigation mitigation items
- Add checkboxes to Next Steps
- All actionable items now have checkboxes for tracking progress
2025-11-05 06:02:34 +00:00
Matthew Raymer
95ac1afcd2 docs: remove independent views, focus on AccountViewView integration only
- Remove ScheduleView, NotificationsView, NotificationHistoryView, NotificationSettingsView
- Remove router routes for independent views
- Remove Pinia store (not needed - state managed locally)
- Remove HomeView diagnostics integration
- Remove native fetcher configuration integration
- Keep only AccountViewView integration with optional supporting components
- Update all phases to focus on AccountViewView only
- Update milestones and testing strategy
- Update dependencies to remove router/pinia references
- Clarify supporting components are optional and only if AccountViewView exceeds length limits
2025-11-05 04:41:27 +00:00
Matthew Raymer
49c62b2b69 Merge branch 'integrate-notification-plugin' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into integrate-notification-plugin 2025-11-05 04:18:25 +00:00
Matthew Raymer
7ae3b241dd docs: add explicit requirement for components to hide on unsupported platforms
- Add CRITICAL REQUIREMENT in Platform Detection Strategy section
- Add Component Visibility Requirements listing all components
- Add Component Visibility Requirements section in Implementation Notes
- Include required pattern with code examples for component hiding
- Add verification checklist for component hiding
- Update Phase 2 tasks to require platform support checks
- Update Phase 3 tasks to require hiding for all notification views
- Add Risk 6 for components visible on unsupported platforms
- Update acceptance criteria to verify component hiding
- Update success criteria to verify hiding on Web/Electron platforms
2025-11-05 04:04:18 +00:00
Matthew Raymer
ced8248436 docs: merge AccountViewView integration strategy into main plan
- Consolidate AccountViewView integration strategy into unified plan
- Add comprehensive AccountViewView Integration Strategy section
- Include UI component design, data flow, and implementation decisions
- Remove separate strategy document to follow meta_feature_planning structure
- Update Phase 2 to include AccountViewView integration tasks
2025-11-05 04:01:50 +00:00
Matthew Raymer
70059e5a31 Merge branch 'master' into integrate-notification-plugin 2025-11-05 02:18:12 +00:00
0e3c6cb314 chore: bump version to 1.1.2-beta 2025-11-04 08:38:01 -07:00
232b787b37 chore: bump to version 1.1.1 build 46 (emojis, starred projects, improved onboarding meetings) 2025-11-04 08:36:08 -07:00
Matthew Raymer
602fe394fa Merge branch 'master' into integrate-notification-plugin 2025-11-04 03:52:52 +00:00
7e861e2fca fix: when organizer adds people, they automatically register them as well 2025-11-03 20:21:34 -07:00
73806e78bc refactor: fix the 'back' links to work consistently, so contact pages can be included in other flows 2025-11-03 19:06:01 -07:00
Matthew Raymer
1f858fa1ce build: configure daily notification plugin for Capacitor Android
- Add plugin project to capacitor.build.gradle
- Register plugin in capacitor.plugins.json
- Include plugin project in capacitor.settings.gradle
2025-11-03 10:04:31 +00:00
Matthew Raymer
f9446f529b chore: add @timesafari/daily-notification-plugin dependency
- Add daily-notification-plugin as local file dependency
- Update package-lock.json with plugin dependency tree
2025-11-03 10:04:30 +00:00
Matthew Raymer
d576920810 docs: add daily notification plugin integration planning documents
- Add comprehensive integration plan following meta_feature_planning workflow
- Add AccountViewView integration strategy with PlatformService approach
- Document architecture decisions: PlatformService interface integration
- Remove web/push notification references
- Document implementation phases and acceptance criteria
2025-11-03 10:03:08 +00:00
Matthew Raymer
1bb3f52a30 chore: fixing missing import for safeStringify 2025-11-02 02:21:32 +00:00
4b1a724246 Merge pull request 'feat: meeting members admission dialog' (#210) from meeting-members-admission-dialog into master
Reviewed-on: #210
2025-10-30 09:58:17 -04:00
Jose Olarte III
d7db7731cf Merge branch 'master' into meeting-members-admission-dialog 2025-10-30 21:55:48 +08:00
Jose Olarte III
9628d5c8c6 refactor: move display text logic to BulkMembersDialog component
- Replace individual text props with single isOrganizer boolean prop
- Add computed properties for title, description, buttonText, and emptyStateText
- Simplify parent component interface by removing text prop passing
- Update quote style from single to double quotes for consistency
- Improve component encapsulation and maintainability
2025-10-30 16:11:45 +08:00
Jose Olarte III
b37051f25d refactor: unify member dialogs into reusable BulkMembersDialog component
- Merge AdmitPendingMembersDialog and SetBulkVisibilityDialog into single BulkMembersDialog
- Add dynamic props for dialog type, title, description, button text, and empty state
- Support both 'admit' and 'visibility' modes with conditional behavior
- Rename setVisibilityForSelectedMembers to addContactWithVisibility for clarity
- Update success counting to track contacts added vs visibility set
- Improve error messages to reflect primary action of adding contacts
- Update MembersList to use unified dialog with role-based configuration
- Remove unused libsUtil import from MembersList
- Update comments and method names to reflect unified functionality
- Rename closeMemberSelectionDialogCallback to closeBulkMembersDialogCallback

This consolidation eliminates ~200 lines of duplicate code while maintaining
all existing functionality and improving maintainability through a single
source of truth for bulk member operations.
2025-10-29 18:21:32 +08:00
Jose Olarte III
7b87ab2a5c feat: add "Select All" footer to member selection dialogs
- Add tfoot with "Select All" checkbox to AdmitPendingMembersDialog
- Add tfoot with "Select All" checkbox to SetBulkVisibilityDialog
- Both footer checkboxes sync with header checkboxes for consistent UX
- Users can now select/deselect all members from top or bottom of table
2025-10-29 15:24:37 +08:00
Jose Olarte III
ca7ead224b fix: resolve PostCSS parsing error in FeedFilters.vue
- Add missing <style scoped> section to FeedFilters.vue to fix PostCSS error

The PostCSS error was occurring because Vue single-file components require
a <style> section, even if empty, for proper CSS processing.
2025-10-29 15:18:54 +08:00
Jose Olarte III
bfc2f07326 fix: resolve admission status styling issues for non-organizers in MembersList
- Fix undefined admitted property for non-organizers by defaulting to true
- Update conditional styling logic to show blue background for non-admitted current user
- Add hand icon indicator for current user in members list
- Improve sorting to prioritize current user after organizer
- Refactor currentUserInList variable inline for cleaner code
- Update text color and hourglass icon conditions to include current user

The server was returning undefined for the admitted property when non-organizers
viewed the members list, causing incorrect styling. Non-organizers now properly
see their admission status and get appropriate visual indicators.
2025-10-28 21:05:06 +08:00
Jose Olarte III
562713d5a4 feat: hide contact instruction when no non-contact members exist
- Add condition to only show "add to contacts" instruction when there are members who are not already contacts
- Use existing getNonContactMembers() method to check for non-contact members
- Fix line length warning by breaking long comment into multiple lines
2025-10-28 18:58:40 +08:00
Jose Olarte III
8100ee5be4 refactor: optimize success message logic in AdmitPendingMembersDialog
- Simplify success message generation using ternary operators
- Remove visibilitySetCount due to its implied nature
- Handle case when contactAddedCount is 0 by omitting contact-related text
- Use more compact logic that only applies ternaries to variable parts
- Maintain proper pluralization for both admitted members and contacts

The message now shows:
- "n member/s admitted." when no contacts added
- "n member/s admitted and added as contact/s." when counts equal
- "n member/s admitted, n added as contact/s." when counts differ
2025-10-28 18:44:36 +08:00
Jose Olarte III
966ca8276d refactor: simplify pending members dialog description text
- Replace verbose explanation with concise, direct question
- Streamline the admission dialog interface
2025-10-28 17:34:16 +08:00
Jose Olarte III
27e38f583b feat: improve auto-refresh handling during member admission dialogs
- Add stopAutoRefresh() calls before showing confirmation dialogs
- Add startAutoRefresh() calls after dialog interactions complete
- Ensure auto-refresh resumes properly in all dialog callback paths
- Fix missing onCancel handler for contact confirmation dialog

This prevents auto-refresh from interfering with user interactions
during member admission workflows while ensuring it resumes afterward.
2025-10-28 17:21:14 +08:00
Jose Olarte III
1e3ecf6d0f refactor: migrate dialog styles from scoped CSS to Tailwind utilities
- Remove scoped CSS styles for .dialog-overlay and .dialog from AdmitPendingMembersDialog.vue
- Remove scoped CSS overflow style from FeedFilters.vue dialog
- Update Tailwind .dialog utility class to include max-height and overflow-y-auto
- Consolidate dialog styling into reusable Tailwind components for consistency
2025-10-28 15:57:36 +08:00
Matthew Raymer
4d9435f257 fix(cursorrules): make system date requirement for documentation only 2025-10-27 03:06:52 +00:00
e8e00d3eae refactor: remove mistakenly-committed file 2025-10-26 14:34:36 -06:00
5c0ce2d1fb fix: linting 2025-10-26 14:09:56 -06:00
9e1c267bc0 refactor: make the meeting member "set visibility" screen much like the organizer's "admit" screen 2025-10-26 14:08:30 -06:00
723a0095a0 feat: prompt user if the pre-commit lint-fix changed anything 2025-10-26 07:43:05 -06:00
9a94843b68 fix: linting 2025-10-26 07:42:34 -06:00
9f3c62a29c test: trying the new pre-commit logic (with a bad linting change) 2025-10-26 07:40:24 -06:00
39173a8db2 fix: linting 2025-10-26 07:35:12 -06:00
7ea6a2ef69 refactor: simplify logic for opening onboarding dialogs 2025-10-25 21:15:32 -06:00
f0f0f1681e chore: move a variable into most local scope 2025-10-24 22:06:53 -06:00
Jose Olarte III
2f1eeb6700 fix: resolve duplicate names in Visibility dialog after Admit dialog
- Add deduplication logic to getMembersForVisibility() method to prevent duplicate entries
- Fix timing issue with isManualRefresh flag reset in showSetBulkVisibilityDialog()
- Ensure Visibility dialog shows each member only once when following Admit dialog
- Remove debugging console logs after issue resolution

The issue was caused by multiple calls to getMembersForVisibility() returning
duplicate member entries, which were then displayed in the Visibility dialog.
The fix deduplicates members by DID to ensure each member appears only once.
2025-10-24 17:31:46 +08:00
Matthew Raymer
a353ed3c3e Merge branch 'master' into clean-db-disconnects 2025-10-24 08:00:17 +00:00
Jose Olarte III
e048e4c86b fix: restrict pending member styling to organizers only
- Apply special styling (blue background, grayed text, hourglass icon) only when current user is organizer
- Non-organizers now see consistent styling for all visible members
- Maintains organizer's ability to distinguish between admitted and pending members
- Fixes issue where non-organizers saw inconsistent styling for all members
2025-10-24 15:40:34 +08:00
Jose Olarte III
16ed5131c4 feat: restrict dialog access based on user roles
- AdmitPendingMembersDialog now only triggers for meeting organizers
- SetBulkVisibilityDialog now only triggers for members who can see other members
- Removes overly restrictive admission status check for visibility dialog
- Ensures proper role-based access control for meeting management features
2025-10-24 15:23:39 +08:00
e6cc058935 test: remove a raw 3-second wait from test utils 2025-10-23 18:04:05 -06:00
Jose Olarte III
ad51c187aa Update AdmitPendingMembersDialog.vue
feat: add DID display to Pending Members dialog

- Restructure member display with better visual hierarchy
- Add DID display with responsive truncation for mobile
- Simplify button labels ("Admit + Add Contacts" and "Admit Only")
2025-10-23 19:59:55 +08:00
Matthew Raymer
37cff0083f fix: resolve Playwright test timing issues with registration status
- Fix async registration check timing in test utilities
- Resolve plus button visibility issues in InviteOneView
- Fix usage limits section loading timing in AccountViewView
- Ensure activeDid is properly set before component rendering

The root cause was timing mismatches between:
1. Async registration checks completing after UI components loaded
2. Usage limits API calls completing after tests expected content
3. ActiveDid initialization completing after conditional rendering

Changes:
- Enhanced waitForRegistrationStatusToSettle() in testUtils.ts
- Added comprehensive timing checks for registration status
- Added usage limits loading verification
- Added activeDid initialization waiting
- Improved error handling and timeout management

Impact:
- All 44 Playwright tests now passing (100% success rate)
- Resolves button click timeouts in invite, project, and offer tests
- Fixes usage limits visibility issues
- Works across both Chromium and Firefox browsers
- Maintains clean, production-ready code without debug logging

Fixes: Multiple test failures including:
- 05-invite.spec.ts: "Check User 0 can invite someone"
- 10-check-usage-limits.spec.ts: "Check usage limits"
- 20-create-project.spec.ts: "Create new project, then search for it"
- 25-create-project-x10.spec.ts: "Create 10 new projects"
- 30-record-gift.spec.ts: "Record something given"
- 37-record-gift-on-project.spec.ts: Project gift tests
- 50-record-offer.spec.ts: Offer tests
2025-10-23 04:17:30 +00:00
Jose Olarte III
6fbc9c2a5b feat: Add AdmitPendingMembersDialog for bulk member admission
- Add new AdmitPendingMembersDialog component with checkbox selection
- Support two action modes: "Admit + Add Contacts" and "Admit Only"
- Integrate dialog into MembersList with proper sequencing
- Show admit dialog before visibility dialog when pending members exist
- Fix auto-refresh pause/resume logic for both dialogs
- Ensure consistent dialog behavior between initial load and manual refresh
- Add proper async/await handling for data refresh operations
- Optimize dialog state management and remove redundant code
- Maintain proper flag timing to prevent race conditions

The admit dialog now shows automatically when there are pending members,
allowing organizers to efficiently admit multiple members at once while
optionally adding them as contacts and setting visibility preferences.
2025-10-22 21:56:00 +08:00
Matthew Raymer
f186e129db refactor(platforms): create BaseDatabaseService to eliminate code duplication
- Create abstract BaseDatabaseService class with common database operations
- Extract 7 duplicate methods from WebPlatformService and CapacitorPlatformService
- Ensure consistent database logic across all platform implementations
- Fix constructor inheritance issues with proper super() calls
- Improve maintainability by centralizing database operations

Methods consolidated:
- generateInsertStatement
- updateDefaultSettings
- updateActiveDid
- getActiveIdentity
- insertNewDidIntoSettings
- updateDidSpecificSettings
- retrieveSettingsForActiveAccount

Architecture:
- BaseDatabaseService (abstract base class)
- WebPlatformService extends BaseDatabaseService
- CapacitorPlatformService extends BaseDatabaseService
- ElectronPlatformService extends CapacitorPlatformService

Benefits:
- Eliminates ~200 lines of duplicate code
- Guarantees consistency across platforms
- Single point of maintenance for database operations
- Prevents platform-specific bugs in database logic

Author: Matthew Raymer
Timestamp: Wed Oct 22 07:26:38 AM UTC 2025
2025-10-22 07:26:38 +00:00
Matthew Raymer
455dfadb92 Merge branch 'master' into clean-db-disconnects
- Resolves merge conflicts from master branch integration
- Includes latest features and bug fixes from master
- Maintains clean-db-disconnects branch functionality

Files affected: Multiple components, views, and utilities
Timestamp: Wed Oct 22 07:26:21 AM UTC 2025
2025-10-22 07:26:21 +00:00
Jose Olarte III
035509224b feat: change icon for pending members
- Changed from an animating spinner to a static hourglass
2025-10-21 22:00:21 +08:00
Jose Olarte III
e9ea89edae feat: enhance members list UI with visual indicators and improved styling
- Sort members list with organizer first, then non-admitted, then admitted
- Add crown icon for meeting organizer identification
- Add spinner icon for non-admitted members
- Implement conditional styling for non-admitted members
- Update button styling to use circle icons instead of rounded backgrounds
- Improve visual hierarchy with better spacing and color coding
2025-10-21 18:13:10 +08:00
Matthew Raymer
fface30123 fix(platforms): include accountDid in settings retrieval for both platforms
- Remove accountDid exclusion from settings object construction in CapacitorPlatformService
- Remove accountDid exclusion from settings object construction in WebPlatformService
- Ensure accountDid is included in retrieved settings for proper DID-specific configuration handling

This change ensures that the accountDid field is properly included when retrieving
settings for the active account, allowing for proper DID-specific configuration
management across both Capacitor (mobile) and Web platforms.

Files modified:
- src/services/platforms/CapacitorPlatformService.ts
- src/services/platforms/WebPlatformService.ts

Timestamp: Wed Oct 8 03:05:45 PM UTC 2025
2025-10-08 15:06:16 +00:00
97b382451a Merge branch 'master' into clean-db-disconnects 2025-10-03 22:18:24 -04:00
Matthew Raymer
7fd2c4e0c7 fix(AccountView): resolve stale registration status cache after identity creation
- Add live registration verification to AccountView.initializeState()
- When settings show unregistered but user has activeDid, verify with server
- Use fetchEndorserRateLimits() matching HomeView's successful pattern
- Update database and UI state immediately upon server confirmation
- Eliminate need to navigate away/back to refresh registration status

Technical details:
- Condition: if (!this.isRegistered && this.activeDid)
- Server check: fetchEndorserRateLimits(this.apiServer, this.axios, this.activeDid)
- On success: $saveUserSettings({isRegistered: true}) + this.isRegistered = true
- Graceful handling for actually unregistered users (expected behavior)

Fixes issue where AccountView showed "Before you can publicly announce..."
message immediately after User Zero identity creation, despite server confirming
user was registered. Problem was Vue component state caching stale settings
while database contained updated registration status.

Resolves behavior reported in iOS testing: User had to navigate to HomeView
and back to AccountView for registration status to update properly.
2025-10-02 08:28:35 +00:00
Matthew Raymer
20322789a2 fix(AccountView): resolve stale registration status cache after identity creation
- Add live registration verification to AccountView.initializeState()
- When settings show unregistered but user has activeDid, verify with server
- Use fetchEndorserRateLimits() matching HomeView's successful pattern
- Update database and UI state immediately upon server confirmation
- Eliminate need to navigate away/back to refresh registration status

Technical details:
- Condition: if (!this.isRegistered && this.activeDid)
- Server check: fetchEndorserRateLimits(this.apiServer, this.axios, this.activeDid)
- On success: $saveUserSettings({isRegistered: true}) + this.isRegistered = true
- Graceful handling for actually unregistered users (expected behavior)

Fixes issue where AccountView showed "Before you can publicly announce..."
message immediately after User Zero identity creation, despite server confirming
user was registered. Problem was Vue component state caching stale settings
while database contained updated registration status.

Resolves behavior reported in iOS testing: User had to navigate to HomeView
and back to AccountView for registration status to update properly.
2025-10-02 08:27:56 +00:00
Matthew Raymer
666bed0efd refactor(services): align Capacitor and Web platform services with active_identity architecture
- Update CapacitorPlatformService.updateDefaultSettings() to use active_identity table instead of hard-coded id=1
- Update CapacitorPlatformService.retrieveSettingsForActiveAccount() to query by accountDid from active_identity
- Add getActiveIdentity() method to CapacitorPlatformService for consistency with WebPlatformService
- Update WebPlatformService.retrieveSettingsForActiveAccount() to match CapacitorPlatformService pattern
- Both services now consistently use active_identity table instead of legacy MASTER_SETTINGS_KEY approach
- Maintains backward compatibility with databaseUtil.ts for PWA migration support

Technical details:
- CapacitorPlatformService: Fixed hard-coded WHERE id = 1 → WHERE accountDid = ?
- WebPlatformService: Fixed retrieval pattern to match new architecture
- Platform services now aligned with migration 004 active_identity table schema
- databaseUtil.ts remains unchanged for PWA-to-SQLite migration bridge
2025-10-02 06:31:03 +00:00
Matthew Raymer
7432525f4c refactor(services): align Capacitor and Web platform services with active_identity architecture
- Update CapacitorPlatformService.updateDefaultSettings() to use active_identity table instead of hard-coded id=1
- Update CapacitorPlatformService.retrieveSettingsForActiveAccount() to query by accountDid from active_identity
- Add getActiveIdentity() method to CapacitorPlatformService for consistency with WebPlatformService
- Update WebPlatformService.retrieveSettingsForActiveAccount() to match CapacitorPlatformService pattern
- Both services now consistently use active_identity table instead of legacy MASTER_SETTINGS_KEY approach
- Maintains backward compatibility with databaseUtil.ts for PWA migration support

Technical details:
- CapacitorPlatformService: Fixed hard-coded WHERE id = 1 → WHERE accountDid = ?
- WebPlatformService: Fixed retrieval pattern to match new architecture
- Platform services now aligned with migration 004 active_identity table schema
- databaseUtil.ts remains unchanged for PWA-to-SQLite migration bridge
2025-10-02 06:29:56 +00:00
530cddfab0 fix: linting 2025-09-29 08:07:54 -06:00
5340c00ae2 fix: remove the duplicate settings for user 0, remove other user-0-specific code, enhance errors 2025-09-28 20:24:49 -06:00
50 changed files with 4492 additions and 1392 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,30 @@
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider> </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> </application>
<!-- Permissions --> <!-- Permissions -->
@@ -45,4 +69,15 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" /> <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> </manifest>

View File

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

View File

@@ -28,3 +28,6 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
include ':capawesome-capacitor-file-picker' include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android') 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

View File

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

46
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.1-beta", "version": "1.1.2-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "timesafari", "name": "timesafari",
"version": "1.1.1-beta", "version": "1.1.2-beta",
"dependencies": { "dependencies": {
"@capacitor-community/electron": "^5.0.1", "@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2", "@capacitor-community/sqlite": "6.0.2",
@@ -37,6 +37,7 @@
"@pvermeer/dexie-encrypted-addon": "^3.0.0", "@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0", "@simplewebauthn/server": "^10.0.0",
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
"@tweenjs/tween.js": "^21.1.1", "@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0", "@veramo/core": "^5.6.0",
@@ -149,6 +150,43 @@
"vite": "^5.2.0" "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": { "node_modules/@0no-co/graphql.web": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
@@ -9605,6 +9643,10 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@timesafari/daily-notification-plugin": {
"resolved": "../daily-notification-plugin",
"link": true
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.1-beta", "version": "1.1.2-beta",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"
@@ -166,6 +166,7 @@
"@pvermeer/dexie-encrypted-addon": "^3.0.0", "@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0", "@simplewebauthn/server": "^10.0.0",
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
"@tweenjs/tween.js": "^21.1.1", "@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0", "@veramo/core": "^5.6.0",

View File

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

View File

@@ -0,0 +1,506 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<div class="text-slate-900 text-center">
<h3 class="text-lg font-semibold leading-[1.25] mb-2">
{{ title }}
</h3>
<p class="text-sm mb-4">
{{ description }}
</p>
<!-- Member Selection Table -->
<div class="mb-4">
<table
class="w-full border-collapse border border-slate-300 text-sm text-start"
>
<!-- Select All Header -->
<thead v-if="membersData && membersData.length > 0">
<tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
/>
Select All
</label>
</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<tr v-if="!membersData || membersData.length === 0">
<td
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
>
{{ emptyStateText }}
</td>
</tr>
<!-- Member Rows -->
<tr
v-for="member in membersData || []"
:key="member.member.memberId"
>
<td class="border border-slate-300 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isMemberSelected(member.did)"
@change="toggleMemberSelection(member.did)"
/>
<div class="">
<div class="text-sm font-semibold">
{{ member.name || SOMEONE_UNNAMED }}
</div>
<div
class="flex items-center gap-0.5 text-xs text-slate-500"
>
<span class="font-semibold sm:hidden">DID:</span>
<span
class="w-[35vw] sm:w-auto truncate text-left"
style="direction: rtl"
>{{ member.did }}</span
>
</div>
</div>
</label>
<!-- Contact indicator - only show if they are already a contact -->
<font-awesome
v-if="member.isContact"
icon="user-circle"
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
@click="showContactInfo"
/>
</div>
</td>
</tr>
</tbody>
<!-- Select All Footer -->
<tfoot v-if="membersData && membersData.length > 0">
<tr class="bg-slate-100 font-medium">
<th class="border border-slate-300 px-3 py-2">
<label class="flex items-center gap-2">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
/>
Select All
</label>
</th>
</tr>
</tfoot>
</table>
</div>
<!-- Action Buttons -->
<div class="space-y-2">
<!-- Main Action Button -->
<button
v-if="membersData && membersData.length > 0"
:disabled="!hasSelectedMembers"
:class="[
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
hasSelectedMembers
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="handleMainAction"
>
{{ buttonText }}
</button>
<!-- Cancel Button -->
<button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click="cancel"
>
Maybe Later
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
import { MemberData } from "@/interfaces";
import { setVisibilityUtil, getHeaders, register } from "@/libs/endorserServer";
import { createNotifyHelpers } from "@/utils/notify";
import { Contact } from "@/db/tables/contacts";
@Component({
mixins: [PlatformServiceMixin],
emits: ["close"],
})
export default class BulkMembersDialog extends Vue {
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
@Prop({ required: true }) dialogType!: "admit" | "visibility";
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
// Component state
membersData: MemberData[] = [];
selectedMembers: string[] = [];
visible = false;
// Constants
// In Vue templates, imported constants need to be explicitly made available to the template
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED;
get hasSelectedMembers() {
return this.selectedMembers.length > 0;
}
get isAllSelected() {
if (!this.membersData || this.membersData.length === 0) return false;
return this.membersData.every((member) =>
this.selectedMembers.includes(member.did),
);
}
get isIndeterminate() {
if (!this.membersData || this.membersData.length === 0) return false;
const selectedCount = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
).length;
return selectedCount > 0 && selectedCount < this.membersData.length;
}
get title() {
return this.isOrganizer
? "Admit Pending Members"
: "Add Members to Contacts";
}
get description() {
return this.isOrganizer
? "Would you like to admit these members to the meeting and add them to your contacts?"
: "Would you like to add these members to your contacts?";
}
get buttonText() {
return this.isOrganizer ? "Admit + Add to Contacts" : "Add to Contacts";
}
get emptyStateText() {
return this.isOrganizer
? "No pending members to admit"
: "No members are not in your contacts";
}
created() {
this.notify = createNotifyHelpers(this.$notify);
}
open(members: MemberData[]) {
this.visible = true;
this.membersData = members;
// Select all by default
this.selectedMembers = this.membersData.map((member) => member.did);
}
close(notSelectedMemberDids: string[]) {
this.visible = false;
this.$emit("close", { notSelectedMemberDids: notSelectedMemberDids });
}
cancel() {
this.close(this.membersData.map((member) => member.did));
}
toggleSelectAll() {
if (!this.membersData || this.membersData.length === 0) return;
if (this.isAllSelected) {
// Deselect all
this.selectedMembers = [];
} else {
// Select all
this.selectedMembers = this.membersData.map((member) => member.did);
}
}
toggleMemberSelection(memberDid: string) {
const index = this.selectedMembers.indexOf(memberDid);
if (index > -1) {
this.selectedMembers.splice(index, 1);
} else {
this.selectedMembers.push(memberDid);
}
}
isMemberSelected(memberDid: string) {
return this.selectedMembers.includes(memberDid);
}
async handleMainAction() {
if (this.dialogType === "admit") {
await this.organizerAdmitAndAddWithVisibility();
} else {
await this.memberAddContactWithVisibility();
}
}
async organizerAdmitAndAddWithVisibility() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers: MemberData[] = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let admittedCount = 0;
let contactAddedCount = 0;
let errors = 0;
for (const member of selectedMembers) {
try {
// First, admit the member
await this.admitMember(member);
// Register them
await this.registerMember(member);
admittedCount++;
// If they're not a contact yet, add them as a contact
if (!member.isContact) {
await this.addAsContact(member, true);
contactAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
errors++;
}
}
// Show success notification
if (admittedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
},
10000,
);
}
if (errors > 0) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to fully admit some members. Work with them individually below.",
},
5000,
);
}
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Some errors occurred. Work with members individually below.",
},
5000,
);
}
}
async memberAddContactWithVisibility() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers: MemberData[] = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let contactsAddedCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member, undefined);
contactsAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added Successfully",
text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error adding contacts:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Some errors occurred. Work with members individually below.",
},
5000,
);
}
}
async admitMember(member: {
did: string;
name: string;
member: { memberId: string };
}) {
try {
const headers = await getHeaders(this.activeDid);
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`,
{ admitted: true },
{ headers },
);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error admitting member:", err);
throw err;
}
}
async registerMember(member: MemberData) {
try {
const contact: Contact = { did: member.did };
const result = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (result.success) {
if (result.embeddedRecordError) {
throw new Error(result.embeddedRecordError);
}
await this.$updateContact(member.did, { registered: true });
} else {
throw result;
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error registering member:", err);
throw err;
}
}
async addAsContact(
member: { did: string; name: string },
isRegistered?: boolean,
) {
try {
const newContact: Contact = {
did: member.did,
name: member.name,
registered: isRegistered,
};
await this.$insertContact(newContact);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error adding contact:", err);
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
// Contact already exists, continue
} else {
throw err; // Re-throw if it's not a duplicate error
}
}
}
async updateContactVisibility(did: string, seesMe: boolean) {
try {
// Get the contact object
const contact = await this.$getContact(did);
if (!contact) {
throw new Error(`Contact not found for DID: ${did}`);
}
// Use the proper API to set visibility on the server
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
contact,
seesMe,
);
if (!result.success) {
throw new Error(result.error || "Failed to set visibility");
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error updating contact visibility:", err);
throw err;
}
}
showContactInfo() {
const message =
this.dialogType === "admit"
? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet.";
this.$notify(
{
group: "alert",
type: "info",
title: "Contact Info",
text: message,
},
5000,
);
}
}
</script>

View File

@@ -2,55 +2,12 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people, GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */ projects, and special entities with selection. * * @author Matthew Raymer */
<template> <template>
<!-- Quick Search --> <ul :class="gridClasses">
<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 --> <!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'"> <template v-if="entityType === 'people'">
<!-- "You" entity --> <!-- "You" entity -->
<SpecialEntityCard <SpecialEntityCard
v-if="showYouEntity && !searchTerm.trim()" v-if="showYouEntity"
entity-type="you" entity-type="you"
label="You" label="You"
icon="hand" icon="hand"
@@ -64,7 +21,6 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity --> <!-- "Unnamed" entity -->
<SpecialEntityCard <SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed" entity-type="unnamed"
:label="unnamedEntityName" :label="unnamedEntityName"
icon="circle-question" icon="circle-question"
@@ -82,60 +38,16 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- Entity cards (people or projects) --> <!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'"> <template v-if="entityType === 'people'">
<!-- When showing contacts without search: split into recent and alphabetical --> <PersonCard
<template v-if="!searchTerm.trim()"> v-for="person in displayedEntities as Contact[]"
<!-- Recently Added Section --> :key="person.did"
<template v-if="recentContacts.length > 0"> :person="person"
<li :conflicted="isPersonConflicted(person.did)"
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300" :show-time-icon="true"
> :notify="notify"
Recently Added :conflict-context="conflictContext"
</li> @person-selected="handlePersonSelected"
<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 Else
</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>
<template v-else-if="entityType === 'projects'"> <template v-else-if="entityType === 'projects'">
@@ -151,27 +63,28 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected" @project-selected="handleProjectSelected"
/> />
</template> </template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul> </ul>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator"; import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import PersonCard from "./PersonCard.vue"; import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue"; import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue"; import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records"; import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* 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 * EntityGrid - Unified grid layout for displaying people or projects
* *
@@ -180,6 +93,7 @@ const RECENT_CONTACTS_COUNT = 3;
* - Special entity integration (You, Unnamed) * - Special entity integration (You, Unnamed)
* - Conflict detection integration * - Conflict detection integration
* - Empty state messaging * - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection * - Event delegation for entity selection
* - Warning notifications for conflicted entities * - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties * - Template streamlined with computed CSS properties
@@ -190,6 +104,7 @@ const RECENT_CONTACTS_COUNT = 3;
PersonCard, PersonCard,
ProjectCard, ProjectCard,
SpecialEntityCard, SpecialEntityCard,
ShowAllCard,
}, },
}) })
export default class EntityGrid extends Vue { export default class EntityGrid extends Vue {
@@ -197,21 +112,14 @@ export default class EntityGrid extends Vue {
@Prop({ required: true }) @Prop({ required: true })
entityType!: "people" | "projects"; entityType!: "people" | "projects";
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/** Array of entities to display */ /** Array of entities to display */
@Prop({ required: true }) @Prop({ required: true })
entities!: Contact[] | PlanData[]; entities!: Contact[] | PlanData[];
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
/** Active user's DID */ /** Active user's DID */
@Prop({ required: true }) @Prop({ required: true })
activeDid!: string; activeDid!: string;
@@ -232,14 +140,18 @@ export default class EntityGrid extends Vue {
@Prop({ default: true }) @Prop({ default: true })
showYouEntity!: boolean; showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */ /** Whether the "You" entity is selectable */
@Prop({ default: true }) @Prop({ default: true })
youSelectable!: boolean; 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 */ /** Notification function from parent component */
@Prop() @Prop()
notify?: (notification: NotificationIface, timeout?: number) => void; notify?: (notification: NotificationIface, timeout?: number) => void;
@@ -248,31 +160,42 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" }) @Prop({ default: "other party" })
conflictContext!: string; conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/** /**
* Function to determine which entities to display (allows parent control) * Function to determine which entities to display (allows parent control)
* *
* This function prop allows parent components to customize which entities * This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering and sorting. * are displayed in the grid, enabling advanced filtering, sorting, and
* Note: Infinite scroll is disabled when this prop is provided. * display logic beyond the default simple slice behavior.
* *
* @param entities - The full array of entities (Contact[] or PlanData[]) * @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects") * @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 * @returns Filtered/sorted array of entities to display
* *
* @example * @example
* // Custom filtering: only show contacts with profile images * // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type) => * :display-entities-function="(entities, type, max) =>
* entities.filter(e => e.profileImageUrl)" * entities.filter(e => e.profileImageUrl).slice(0, max)"
* *
* @example * @example
* // Custom sorting: sort projects by name * // Custom sorting: sort projects by name
* :display-entities-function="(entities, type) => * :display-entities-function="(entities, type, max) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))" * 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)"
*/ */
@Prop({ default: null }) @Prop({ default: null })
displayEntitiesFunction?: ( displayEntitiesFunction?: (
entities: Contact[] | PlanData[], entities: Contact[] | PlanData[],
entityType: "people" | "projects", entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[]; ) => Contact[] | PlanData[];
/** /**
@@ -283,60 +206,33 @@ export default class EntityGrid extends Vue {
} }
/** /**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll * Computed CSS classes for the grid layout
* When searching, returns filtered results with infinite scroll applied */
get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise defaults
*/ */
get displayedEntities(): Contact[] | PlanData[] { get displayedEntities(): Contact[] | PlanData[] {
// If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) { if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType); return this.displayEntitiesFunction(
this.entities,
this.entityType,
this.maxItems,
);
} }
// Default: projects use infinite scroll // Default implementation for backward compatibility
if (this.entityType === "projects") { const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return (this.entities as PlanData[]).slice(0, this.displayedCount); return this.entities.slice(0, maxDisplay);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Get the 3 most recently added contacts (when showing contacts and not searching)
*/
get recentContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, 3);
}
/**
* 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()) {
return [];
}
// Skip the first 3 (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
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 3 recent)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
} }
/** /**
@@ -350,6 +246,15 @@ 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 * Whether the "You" entity is conflicted
*/ */
@@ -423,144 +328,6 @@ 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
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
try {
// Simulate async search (in case we need to add API calls later)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
if (this.entityType === "people") {
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);
});
} else {
this.filteredEntities = (this.entities as PlanData[])
.filter((project: PlanData) => {
const name = project.name?.toLowerCase() || "";
const handleId = project.handleId.toLowerCase();
return name.includes(searchLower) || handleId.includes(searchLower);
})
.sort((a: PlanData, b: PlanData) => {
// Sort alphabetically by name
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
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 filtered entities
return this.displayedCount < this.filteredEntities.length;
}
if (this.entityType === "projects") {
// Projects: check if more available
return this.displayedCount < this.entities.length;
}
// People: check if more alphabetical contacts available
// Total available = 3 recent + all alphabetical
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
mounted(): void {
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
() => {
// Load more: increment displayedCount
this.displayedCount += INCREMENT_SIZE;
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator // Emit methods using @Emit decorator
@Emit("entity-selected") @Emit("entity-selected")
@@ -573,33 +340,6 @@ export default class EntityGrid extends Vue {
} { } {
return data; return data;
} }
/**
* Watch for changes in search term to reset displayed count
*/
@Watch("searchTerm")
onSearchTermChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
} }
</script> </script>

View File

@@ -3,9 +3,10 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * - based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * - Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Cancel functionality * - Event delegation for entity selection * - Warning Show All navigation with context preservation * - Cancel functionality * - Event
notifications for conflicted entities * - Template streamlined with computed CSS delegation for entity selection * - Warning notifications for conflicted
properties * * @author Matthew Raymer */ entities * - Template streamlined with computed CSS properties * * @author
Matthew Raymer */
<template> <template>
<div id="sectionGiftedGiver"> <div id="sectionGiftedGiver">
<label class="block font-bold mb-4"> <label class="block font-bold mb-4">
@@ -15,14 +16,18 @@ properties * * @author Matthew Raymer */
<EntityGrid <EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'" :entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts" :entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
:all-contacts="allContacts" :all-contacts="allContacts"
:conflict-checker="conflictChecker" :conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity" :show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable" :you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify" :notify="notify"
:conflict-context="conflictContext" :conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected" @entity-selected="handleEntitySelected"
/> />
@@ -63,6 +68,7 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display * - EntityGrid integration for unified entity display
* - Conflict detection and prevention * - Conflict detection and prevention
* - Special entity handling (You, Unnamed) * - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality * - Cancel functionality
* - Event delegation for entity selection * - Event delegation for entity selection
* - Warning notifications for conflicted entities * - Warning notifications for conflicted entities
@@ -148,6 +154,10 @@ export default class EntitySelectionStep extends Vue {
@Prop() @Prop()
notify?: (notification: NotificationIface, timeout?: number) => void; notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/** /**
* CSS classes for the cancel button * CSS classes for the cancel button
*/ */
@@ -212,6 +222,59 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid); 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 * Handle entity selection from EntityGrid
*/ */

View File

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

View File

@@ -29,6 +29,7 @@
:unit-code="unitCode" :unit-code="unitCode"
:offer-id="offerId" :offer-id="offerId"
:notify="$notify" :notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected" @entity-selected="handleEntitySelected"
@cancel="cancel" @cancel="cancel"
/> />
@@ -116,6 +117,7 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = ""; @Prop() fromProjectId = "";
@Prop() toProjectId = ""; @Prop() toProjectId = "";
@Prop() isFromProjectView = false; @Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as @Prop({ default: "person" }) giverEntityType = "person" as
| "person" | "person"
| "project"; | "project";
@@ -231,7 +233,7 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer, apiServer: this.apiServer,
}); });
this.allContacts = await this.$contactsByDateAdded(); this.allContacts = await this.$contacts();
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -104,6 +104,71 @@ 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 * Extract JWT from various URL formats
* @param jwtUrlText The URL containing the JWT * @param jwtUrlText The URL containing the JWT

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,68 @@ export interface PlatformCapabilities {
isNativeApp: boolean; 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. * Platform-agnostic interface for handling platform-specific operations.
* Provides a common API for file system operations, camera interactions, * Provides a common API for file system operations, camera interactions,
@@ -209,6 +271,58 @@ export interface PlatformService {
*/ */
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>; 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) --- // --- PWA/Web-only methods (optional, only implemented on web) ---
/** /**
* Registers the service worker for PWA support (web only) * Registers the service worker for PWA support (web only)

View File

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

View File

@@ -20,8 +20,14 @@ import {
ImageResult, ImageResult,
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities,
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { BaseDatabaseService } from "./BaseDatabaseService";
interface QueuedOperation { interface QueuedOperation {
type: "run" | "query" | "rawQuery"; type: "run" | "query" | "rawQuery";
@@ -39,7 +45,10 @@ interface QueuedOperation {
* - Platform-specific features * - Platform-specific features
* - SQLite database operations * - SQLite database operations
*/ */
export class CapacitorPlatformService implements PlatformService { export class CapacitorPlatformService
extends BaseDatabaseService
implements PlatformService
{
/** Current camera direction */ /** Current camera direction */
private currentDirection: CameraDirection = CameraDirection.Rear; private currentDirection: CameraDirection = CameraDirection.Rear;
@@ -52,6 +61,7 @@ export class CapacitorPlatformService implements PlatformService {
private isProcessingQueue: boolean = false; private isProcessingQueue: boolean = false;
constructor() { constructor() {
super();
this.sqlite = new SQLiteConnection(CapacitorSQLite); this.sqlite = new SQLiteConnection(CapacitorSQLite);
} }
@@ -1328,79 +1338,447 @@ export class CapacitorPlatformService implements PlatformService {
// --- PWA/Web-only methods (no-op for Capacitor) --- // --- PWA/Web-only methods (no-op for Capacitor) ---
public registerServiceWorker(): void {} public registerServiceWorker(): void {}
// Database utility methods // Daily notification operations
generateInsertStatement( /**
model: Record<string, unknown>, * Get the status of scheduled daily notifications
tableName: string, * @see PlatformService.getDailyNotificationStatus
): { sql: string; params: unknown[] } { */
const keys = Object.keys(model); async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
const placeholders = keys.map(() => "?").join(", "); try {
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; // Dynamic import to avoid build issues if plugin unavailable
const params = keys.map((key) => model[key]); const { DailyNotification } = await import(
return { sql, params }; "@timesafari/daily-notification-plugin"
} );
async updateDefaultSettings( const pluginStatus = await DailyNotification.getNotificationStatus();
settings: Record<string, unknown>,
): Promise<void> {
const keys = Object.keys(settings);
const setClause = keys.map((key) => `${key} = ?`).join(", ");
const sql = `UPDATE settings SET ${setClause} WHERE id = 1`;
const params = keys.map((key) => settings[key]);
await this.dbExec(sql, params);
}
async updateActiveDid(did: string): Promise<void> { // Get permissions separately
await this.dbExec( const permissions = await DailyNotification.checkPermissions();
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[did],
);
}
async insertNewDidIntoSettings(did: string): Promise<void> { // Map plugin PermissionState to our PermissionStatus format
// Import constants dynamically to avoid circular dependencies const notificationsPermission = permissions.notifications;
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = let notifications: "granted" | "denied" | "prompt";
await import("@/constants/app");
// Use INSERT OR REPLACE to handle case where settings already exist for this DID if (notificationsPermission === "granted") {
// This prevents duplicate accountDid entries and ensures data integrity notifications = "granted";
await this.dbExec( } else if (notificationsPermission === "denied") {
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)", notifications = "denied";
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER], } else {
); notifications = "prompt";
} }
async updateDidSpecificSettings( // Handle lastNotificationTime which can be a Promise<number>
did: string, let lastTriggered: string | undefined;
settings: Record<string, unknown>, const lastNotificationTime = pluginStatus.lastNotificationTime;
): Promise<void> { if (lastNotificationTime) {
const keys = Object.keys(settings); const timeValue = await Promise.resolve(lastNotificationTime);
const setClause = keys.map((key) => `${key} = ?`).join(", "); if (typeof timeValue === "number") {
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; lastTriggered = new Date(timeValue).toISOString();
const params = [...keys.map((key) => settings[key]), did];
await this.dbExec(sql, params);
}
async retrieveSettingsForActiveAccount(): Promise<Record<
string,
unknown
> | null> {
const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1");
if (result?.values?.[0]) {
// Convert the row to an object
const row = result.values[0];
const columns = result.columns || [];
const settings: Record<string, unknown> = {};
columns.forEach((column, index) => {
if (column !== "id") {
// Exclude the id column
settings[column] = row[index];
} }
}
return {
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",
}); });
return settings; logger.info(
`[CapacitorPlatformService] Scheduled daily notification for ${options.time}`,
);
} catch (error) {
logger.error(
"[CapacitorPlatformService] Failed to schedule notification:",
error,
);
throw error;
} }
return null;
} }
/**
* 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,
// retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
} }

View File

@@ -22,6 +22,13 @@
import { CapacitorPlatformService } from "./CapacitorPlatformService"; import { CapacitorPlatformService } from "./CapacitorPlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import {
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService";
/** /**
* Electron-specific platform service implementation. * Electron-specific platform service implementation.
@@ -166,4 +173,88 @@ export class ElectronPlatformService extends CapacitorPlatformService {
// --- PWA/Web-only methods (no-op for Electron) --- // --- PWA/Web-only methods (no-op for Electron) ---
public registerServiceWorker(): void {} 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,9 +2,15 @@ import {
ImageResult, ImageResult,
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities,
NotificationStatus,
PermissionStatus,
PermissionResult,
ScheduleOptions,
NativeFetcherConfig,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database"; import { QueryExecResult } from "@/interfaces/database";
import { BaseDatabaseService } from "./BaseDatabaseService";
// Dynamic import of initBackend to prevent worker context errors // Dynamic import of initBackend to prevent worker context errors
import type { import type {
WorkerRequest, WorkerRequest,
@@ -29,7 +35,10 @@ import type {
* Note: File system operations are not available in the web platform * Note: File system operations are not available in the web platform
* due to browser security restrictions. These methods throw appropriate errors. * due to browser security restrictions. These methods throw appropriate errors.
*/ */
export class WebPlatformService implements PlatformService { export class WebPlatformService
extends BaseDatabaseService
implements PlatformService
{
private static instanceCount = 0; // Debug counter private static instanceCount = 0; // Debug counter
private worker: Worker | null = null; private worker: Worker | null = null;
private workerReady = false; private workerReady = false;
@@ -46,6 +55,7 @@ export class WebPlatformService implements PlatformService {
private readonly messageTimeout = 30000; // 30 seconds private readonly messageTimeout = 30000; // 30 seconds
constructor() { constructor() {
super();
WebPlatformService.instanceCount++; WebPlatformService.instanceCount++;
logger.debug("[WebPlatformService] Initializing web platform service"); logger.debug("[WebPlatformService] Initializing web platform service");
@@ -668,105 +678,85 @@ export class WebPlatformService implements PlatformService {
// SharedArrayBuffer initialization is handled by initBackend call in initializeWorker // SharedArrayBuffer initialization is handled by initBackend call in initializeWorker
} }
// Database utility methods // Database utility methods - inherited from BaseDatabaseService
generateInsertStatement( // generateInsertStatement, updateDefaultSettings, updateActiveDid,
model: Record<string, unknown>, // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,
tableName: string, // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService
): { sql: string; params: unknown[] } {
const keys = Object.keys(model); // Daily notification operations
const placeholders = keys.map(() => "?").join(", "); /**
const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; * Get the status of scheduled daily notifications
const params = keys.map((key) => model[key]); * @see PlatformService.getDailyNotificationStatus
return { sql, params }; * @returns null - notifications not supported on web platform
*/
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
return null;
} }
async updateDefaultSettings( /**
settings: Record<string, unknown>, * Check notification permissions
): Promise<void> { * @see PlatformService.checkNotificationPermissions
// Get current active DID and update that identity's settings * @returns null - notifications not supported on web platform
const activeIdentity = await this.getActiveIdentity(); */
const activeDid = activeIdentity.activeDid; async checkNotificationPermissions(): Promise<PermissionStatus | null> {
return null;
if (!activeDid) {
logger.warn(
"[WebPlatformService] No active DID found, cannot update default settings",
);
return;
}
const keys = Object.keys(settings);
const setClause = keys.map((key) => `${key} = ?`).join(", ");
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`;
const params = [...keys.map((key) => settings[key]), activeDid];
await this.dbExec(sql, params);
} }
async updateActiveDid(did: string): Promise<void> { /**
await this.dbExec( * Request notification permissions
"INSERT OR REPLACE INTO active_identity (id, activeDid, lastUpdated) VALUES (1, ?, ?)", * @see PlatformService.requestNotificationPermissions
[did, new Date().toISOString()], * @returns null - notifications not supported on web platform
); */
async requestNotificationPermissions(): Promise<PermissionResult | null> {
return null;
} }
async getActiveIdentity(): Promise<{ activeDid: string }> { /**
const result = await this.dbQuery( * Schedule a daily notification
"SELECT activeDid FROM active_identity WHERE id = 1", * @see PlatformService.scheduleDailyNotification
); * @throws Error - notifications not supported on web platform
return { */
activeDid: (result?.values?.[0]?.[0] as string) || "", async scheduleDailyNotification(_options: ScheduleOptions): Promise<void> {
}; throw new Error("Daily notifications are not supported on web platform");
} }
async insertNewDidIntoSettings(did: string): Promise<void> { /**
// Import constants dynamically to avoid circular dependencies * Cancel scheduled daily notification
const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = * @see PlatformService.cancelDailyNotification
await import("@/constants/app"); * @throws Error - notifications not supported on web platform
*/
// Use INSERT OR REPLACE to handle case where settings already exist for this DID async cancelDailyNotification(): Promise<void> {
// This prevents duplicate accountDid entries and ensures data integrity throw new Error("Daily notifications are not supported on web platform");
await this.dbExec(
"INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)",
[did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER],
);
} }
async updateDidSpecificSettings( /**
did: string, * Configure native fetcher for background operations
settings: Record<string, unknown>, * @see PlatformService.configureNativeFetcher
): Promise<void> { * @returns null - native fetcher not supported on web platform
const keys = Object.keys(settings); */
const setClause = keys.map((key) => `${key} = ?`).join(", "); async configureNativeFetcher(
const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; _config: NativeFetcherConfig,
const params = [...keys.map((key) => settings[key]), did]; ): Promise<void | null> {
// Log update operation for debugging return null;
logger.debug(
"[WebPlatformService] updateDidSpecificSettings",
sql,
JSON.stringify(params, null, 2),
);
await this.dbExec(sql, params);
} }
async retrieveSettingsForActiveAccount(): Promise<Record< /**
string, * Update starred plans for background fetcher
unknown * @see PlatformService.updateStarredPlans
> | null> { * @returns null - native fetcher not supported on web platform
const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1"); */
if (result?.values?.[0]) { async updateStarredPlans(_plans: {
// Convert the row to an object planIds: string[];
const row = result.values[0]; }): Promise<void | null> {
const columns = result.columns || []; return null;
const settings: Record<string, unknown> = {}; }
columns.forEach((column, index) => { /**
if (column !== "id") { * Open the app's notification settings in the system settings
// Exclude the id column * @see PlatformService.openAppNotificationSettings
settings[column] = row[index]; * @returns null - not supported on web platform
} */
}); async openAppNotificationSettings(): Promise<void | null> {
return settings;
}
return null; return null;
} }
} }

View File

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

View File

@@ -970,20 +970,6 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts); 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 * Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total number of contacts * @returns Promise<number> Total number of contacts
@@ -2071,7 +2057,6 @@ declare module "@vue/runtime-core" {
// Specialized shortcuts - contacts cached, settings fresh // Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>; $contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>; $contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>; $settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>; $accountSettings(did?: string, defaults?: Settings): Promise<Settings>;

View File

@@ -161,6 +161,9 @@
</section> </section>
<PushNotificationPermission ref="pushNotificationPermission" /> <PushNotificationPermission ref="pushNotificationPermission" />
<!-- Daily Notifications (Native) -->
<DailyNotificationSection />
<!-- User Profile --> <!-- User Profile -->
<section <section
v-if="isRegistered" v-if="isRegistered"
@@ -790,6 +793,7 @@ import IdentitySection from "@/components/IdentitySection.vue";
import RegistrationNotice from "@/components/RegistrationNotice.vue"; import RegistrationNotice from "@/components/RegistrationNotice.vue";
import LocationSearchSection from "@/components/LocationSearchSection.vue"; import LocationSearchSection from "@/components/LocationSearchSection.vue";
import UsageLimitsSection from "@/components/UsageLimitsSection.vue"; import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
import DailyNotificationSection from "@/components/notifications/DailyNotificationSection.vue";
import { import {
AppString, AppString,
DEFAULT_IMAGE_API_SERVER, DEFAULT_IMAGE_API_SERVER,
@@ -858,6 +862,7 @@ interface UserNameDialogRef {
RegistrationNotice, RegistrationNotice,
LocationSearchSection, LocationSearchSection,
UsageLimitsSection, UsageLimitsSection,
DailyNotificationSection,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@@ -1488,18 +1493,21 @@ export default class AccountViewView extends Vue {
status?: number; status?: number;
}; };
}; };
logger.error("[Server Limits] Error retrieving limits:", { logger.warn(
error: error instanceof Error ? error.message : String(error), "[Server Limits] Error retrieving limits, expected for unregistered users:",
did: did, {
apiServer: this.apiServer, error: error instanceof Error ? error.message : String(error),
imageServer: this.DEFAULT_IMAGE_API_SERVER, did: did,
partnerApiServer: this.partnerApiServer, apiServer: this.apiServer,
errorCode: axiosError?.response?.data?.error?.code, imageServer: this.DEFAULT_IMAGE_API_SERVER,
errorMessage: axiosError?.response?.data?.error?.message, partnerApiServer: this.partnerApiServer,
httpStatus: axiosError?.response?.status, errorCode: axiosError?.response?.data?.error?.code,
needsUserMigration: true, errorMessage: axiosError?.response?.data?.error?.message,
timestamp: new Date().toISOString(), httpStatus: axiosError?.response?.status,
}); needsUserMigration: true,
timestamp: new Date().toISOString(),
},
);
// this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD); // this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD);
} finally { } finally {

View File

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

View File

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

View File

@@ -12,20 +12,20 @@
</h1> </h1>
<!-- Back --> <!-- Back -->
<router-link <button
class="order-first text-lg text-center leading-none p-1" class="order-first text-lg text-center leading-none p-1"
:to="{ name: 'contacts' }" @click="goBack()"
> >
<font-awesome icon="chevron-left" class="block text-center w-[1em]" /> <font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</router-link> </button>
<!-- Help button --> <!-- Help button -->
<router-link <button
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full" class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="goToHelp()"
> >
<font-awesome icon="question" class="block text-center w-[1em]" /> <font-awesome icon="question" class="block text-center w-[1em]" />
</router-link> </button>
</div> </div>
<!-- Identity Details --> <!-- Identity Details -->
@@ -476,7 +476,7 @@ export default class DIDView extends Vue {
* Navigation helper methods * Navigation helper methods
*/ */
goBack() { goBack() {
this.$router.go(-1); this.$router.back();
} }
/** /**

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,23 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts"; import { createBuildConfig } from "./vite.config.common.mts";
export default defineConfig(async () => createBuildConfig('capacitor')); 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,
}
}
}
};
});