Compare commits

..

11 Commits

57 changed files with 1622 additions and 1437 deletions

View File

@@ -617,8 +617,7 @@ The Electron build process follows a multi-stage approach:
#### **Stage 2: Capacitor Sync**
- Copies web assets to Electron app directory
- Uses Electron-specific Capacitor configuration (not copied from main config)
- Syncs Capacitor plugins for Electron platform
- Syncs Capacitor configuration and plugins
- Prepares native module bindings
#### **Stage 3: TypeScript Compile**

View File

@@ -1,181 +0,0 @@
# Seed Phrase Backup Reminder Implementation
## Overview
This implementation adds a modal dialog that reminds users to back up their seed phrase if they haven't done so yet. The reminder appears after specific user actions and includes a 24-hour cooldown to avoid being too intrusive.
## Features
- **Modal Dialog**: Uses the existing notification group modal system from `App.vue`
- **Smart Timing**: Only shows when `hasBackedUpSeed = false`
- **24-Hour Cooldown**: Uses localStorage to prevent showing more than once per day
- **Action-Based Triggers**: Shows after specific user actions
- **User Choice**: "Backup Identifier Seed" or "Remind me Later" options
## Implementation Details
### Core Utility (`src/utils/seedPhraseReminder.ts`)
The main utility provides:
- `shouldShowSeedReminder(hasBackedUpSeed)`: Checks if reminder should be shown
- `markSeedReminderShown()`: Updates localStorage timestamp
- `createSeedReminderNotification()`: Creates the modal configuration
- `showSeedPhraseReminder(hasBackedUpSeed, notifyFunction)`: Main function to show reminder
### Trigger Points
The reminder is shown after these user actions:
**Note**: The reminder is triggered by **claim creation** actions, not claim confirmations. This focuses on when users are actively creating new content rather than just confirming existing claims.
1. **Profile Saving** (`AccountViewView.vue`)
- After clicking "Save Profile" button
- Only when profile save is successful
2. **Claim Creation** (Multiple views)
- `ClaimAddRawView.vue`: After submitting raw claims
- `GiftedDialog.vue`: After creating gifts/claims
- `GiftedDetailsView.vue`: After recording gifts/claims
- `OfferDialog.vue`: After creating offers
3. **QR Code Views Exit**
- `ContactQRScanFullView.vue`: When exiting via back button
- `ContactQRScanShowView.vue`: When exiting via back button
### Modal Configuration
```typescript
{
group: "modal",
type: "confirm",
title: "Backup Your Identifier Seed?",
text: "It looks like you haven't backed up your identifier seed yet. It's important to back it up as soon as possible to secure your identity.",
yesText: "Backup Identifier Seed",
noText: "Remind me Later",
onYes: () => navigate to /seed-backup,
onNo: () => mark as shown for 24 hours,
onCancel: () => mark as shown for 24 hours
}
```
**Important**: The modal is configured with `timeout: -1` to ensure it stays open until the user explicitly interacts with one of the buttons. This prevents the dialog from closing automatically.
### Cooldown Mechanism
- **Storage Key**: `seedPhraseReminderLastShown`
- **Cooldown Period**: 24 hours (24 * 60 * 60 * 1000 milliseconds)
- **Implementation**: localStorage with timestamp comparison
- **Fallback**: Shows reminder if timestamp is invalid or missing
## User Experience
### When Reminder Appears
- User has not backed up their seed phrase (`hasBackedUpSeed = false`)
- At least 24 hours have passed since last reminder
- User performs one of the trigger actions
- **1-second delay** after the success message to allow users to see the confirmation
### User Options
1. **"Backup Identifier Seed"**: Navigates to `/seed-backup` page
2. **"Remind me Later"**: Dismisses and won't show again for 24 hours
3. **Cancel/Close**: Same behavior as "Remind me Later"
### Frequency Control
- **First Time**: Always shows if user hasn't backed up
- **Subsequent**: Only shows after 24-hour cooldown
- **Automatic Reset**: When user completes seed backup (`hasBackedUpSeed = true`)
## Technical Implementation
### Error Handling
- Graceful fallback if localStorage operations fail
- Logging of errors for debugging
- Non-blocking implementation (doesn't affect main functionality)
### Integration Points
- **Platform Service**: Uses `$accountSettings()` to check backup status
- **Notification System**: Integrates with existing `$notify` system
- **Router**: Uses `window.location.href` for navigation
### Performance Considerations
- Minimal localStorage operations
- No blocking operations
- Efficient timestamp comparisons
- **Timing Behavior**: 1-second delay before showing reminder to improve user experience flow
## Testing
### Manual Testing Scenarios
1. **First Time User**
- Create new account
- Perform trigger action (save profile, create claim, exit QR view)
- Verify reminder appears
2. **Repeat User (Within 24h)**
- Perform trigger action
- Verify reminder does NOT appear
3. **Repeat User (After 24h)**
- Wait 24+ hours
- Perform trigger action
- Verify reminder appears again
4. **User Who Has Backed Up**
- Complete seed backup
- Perform trigger action
- Verify reminder does NOT appear
5. **QR Code View Exit**
- Navigate to QR code view (full or show)
- Exit via back button
- Verify reminder appears (if conditions are met)
### Browser Testing
- Test localStorage functionality
- Verify timestamp handling
- Check navigation to seed backup page
## Future Enhancements
### Potential Improvements
1. **Customizable Cooldown**: Allow users to set reminder frequency
2. **Progressive Urgency**: Increase reminder frequency over time
3. **Analytics**: Track reminder effectiveness and user response
4. **A/B Testing**: Test different reminder messages and timing
### Configuration Options
- Reminder frequency settings
- Custom reminder messages
- Different trigger conditions
- Integration with other notification systems
## Maintenance
### Monitoring
- Check localStorage usage in browser dev tools
- Monitor user feedback about reminder frequency
- Track navigation success to seed backup page
### Updates
- Modify reminder text in `createSeedReminderNotification()`
- Adjust cooldown period in `REMINDER_COOLDOWN_MS` constant
- Add new trigger points as needed
## Conclusion
This implementation provides a non-intrusive way to remind users about seed phrase backup while respecting their preferences and avoiding notification fatigue. The 24-hour cooldown ensures users aren't overwhelmed while maintaining the importance of the security reminder.
The feature is fully integrated with the existing codebase architecture and follows established patterns for notifications, error handling, and user interaction.

View File

@@ -1,116 +0,0 @@
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.timesafari',
appName: 'TimeSafari',
webDir: 'dist',
server: {
cleartext: true
},
plugins: {
App: {
appUrlOpen: {
handlers: [
{
url: 'timesafari://*',
autoVerify: true
}
]
}
},
SplashScreen: {
launchShowDuration: 3000,
launchAutoHide: true,
backgroundColor: '#ffffff',
androidSplashResourceName: 'splash',
androidScaleType: 'CENTER_CROP',
showSpinner: false,
androidSpinnerStyle: 'large',
iosSpinnerStyle: 'small',
spinnerColor: '#999999',
splashFullScreen: true,
splashImmersive: true
},
CapSQLite: {
iosDatabaseLocation: 'Library/CapacitorDatabase',
iosIsEncryption: false,
iosBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
},
androidIsEncryption: false,
androidBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
},
electronIsEncryption: false
}
},
ios: {
contentInset: 'never',
allowsLinkPreview: true,
scrollEnabled: true,
limitsNavigationsToAppBoundDomains: true,
backgroundColor: '#ffffff',
allowNavigation: [
'*.timesafari.app',
'*.jsdelivr.net',
'api.endorser.ch'
]
},
android: {
allowMixedContent: true,
captureInput: true,
webContentsDebuggingEnabled: false,
allowNavigation: [
'*.timesafari.app',
'*.jsdelivr.net',
'api.endorser.ch',
'10.0.2.2:3000'
]
},
electron: {
deepLinking: {
schemes: ['timesafari']
},
buildOptions: {
appId: 'app.timesafari',
productName: 'TimeSafari',
directories: {
output: 'dist-electron-packages'
},
files: [
'dist/**/*',
'electron/**/*'
],
mac: {
category: 'public.app-category.productivity',
target: [
{
target: 'dmg',
arch: ['x64', 'arm64']
}
]
},
win: {
target: [
{
target: 'nsis',
arch: ['x64']
}
]
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64']
}
],
category: 'Utility'
}
}
}
};
export default config;

View File

@@ -56,6 +56,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz",
"integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==",
"license": "MIT",
"dependencies": {
"jeep-sqlite": "^2.7.2"
},

View File

@@ -50,7 +50,6 @@ process.stderr.on('error', (err) => {
const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })];
const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' },
];

View File

@@ -53,7 +53,6 @@ export class ElectronCapacitorApp {
];
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' },
];
private mainWindowState;

View File

@@ -1,6 +1,6 @@
{
"compileOnSave": true,
"include": ["./src/**/*"],
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"],
"compilerOptions": {
"outDir": "./build",
"importHelpers": true,

91
package-lock.json generated
View File

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

View File

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

View File

@@ -181,7 +181,7 @@ sync_capacitor() {
copy_web_assets() {
log_info "Copying web assets to Electron"
safe_execute "Copying assets" "cp -r dist/* electron/app/"
# Note: Electron has its own capacitor.config.ts file, so we don't copy the main config
safe_execute "Copying config" "cp capacitor.config.json electron/capacitor.config.json"
}
# Compile TypeScript

View File

@@ -22,4 +22,24 @@
.dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg;
}
/* Markdown content styling to restore list elements */
.markdown-content ul {
@apply list-disc list-inside ml-4;
}
.markdown-content ol {
@apply list-decimal list-inside ml-4;
}
.markdown-content li {
@apply mb-1;
}
.markdown-content ul ul,
.markdown-content ol ol,
.markdown-content ul ol,
.markdown-content ol ul {
@apply ml-4 mt-1;
}
}

View File

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

View File

@@ -16,12 +16,6 @@ messages * - Conditional UI based on platform capabilities * * @component *
:to="{ name: 'seed-backup' }"
:class="backupButtonClasses"
>
<!-- Notification dot - show while the user has not yet backed up their seed phrase -->
<font-awesome
v-if="!hasBackedUpSeed"
icon="circle"
class="absolute -right-[8px] -top-[8px] text-rose-500 text-[14px] border border-white rounded-full"
></font-awesome>
Backup Identifier Seed
</router-link>
@@ -104,12 +98,6 @@ export default class DataExportSection extends Vue {
*/
isExporting = false;
/**
* Flag indicating if the user has backed up their seed phrase
* Used to control the visibility of the notification dot
*/
hasBackedUpSeed = false;
/**
* Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called
@@ -141,7 +129,7 @@ export default class DataExportSection extends Vue {
* CSS classes for the backup button (router link)
*/
get backupButtonClasses(): string {
return "block relative w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2";
return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2";
}
/**
@@ -230,22 +218,6 @@ export default class DataExportSection extends Vue {
created() {
this.notify = createNotifyHelpers(this.$notify);
this.loadSeedBackupStatus();
}
/**
* Loads the seed backup status from account settings
* Updates the hasBackedUpSeed flag to control notification dot visibility
*/
private async loadSeedBackupStatus(): Promise<void> {
try {
const settings = await this.$accountSettings();
this.hasBackedUpSeed = !!settings.hasBackedUpSeed;
} catch (err: unknown) {
logger.error("Failed to load seed backup status:", err);
// Default to false (show notification dot) if we can't load the setting
this.hasBackedUpSeed = false;
}
}
}
</script>

View File

@@ -82,7 +82,6 @@ import GiftDetailsStep from "../components/GiftDetailsStep.vue";
import { PlanData } from "../interfaces/records";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
NOTIFY_GIFT_ERROR_NO_DESCRIPTION,
@@ -412,15 +411,6 @@ export default class GiftedDialog extends Vue {
);
} else {
this.safeNotify.success("That gift was recorded.", TIMEOUTS.VERY_LONG);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount);
}

View File

@@ -74,7 +74,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyTextToClipboard('A link to this page', deepLinkUrl)"
@click="copyToClipboard('A link to this page', deepLinkUrl)"
>click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a
>
@@ -110,7 +110,7 @@
* @since 2024-12-19
*/
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import * as R from "ramda";
import * as serverUtil from "../libs/endorserServer";
import { Contact } from "../db/tables/contacts";
@@ -197,24 +197,19 @@ export default class HiddenDidDialog extends Vue {
);
}
async copyTextToClipboard(name: string, text: string) {
try {
await copyToClipboard(text);
this.notify.success(
NOTIFY_COPIED_TO_CLIPBOARD.message(name || "That"),
TIMEOUTS.SHORT,
);
} catch (error) {
this.$logAndConsole(
`Error copying ${name || "content"} to clipboard: ${error}`,
true,
);
this.notify.error(`Failed to copy ${name || "content"} to clipboard.`);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.notify.success(
NOTIFY_COPIED_TO_CLIPBOARD.message(name || "That"),
TIMEOUTS.SHORT,
);
});
}
onClickShareClaim() {
this.copyTextToClipboard("A link to this page", this.deepLinkUrl);
this.copyToClipboard("A link to this page", this.deepLinkUrl);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",

View File

@@ -64,7 +64,6 @@ import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_OFFER_SETTINGS_ERROR,
NOTIFY_OFFER_RECORDING,
@@ -300,14 +299,6 @@ export default class OfferDialog extends Vue {
);
} else {
this.notify.success(NOTIFY_OFFER_SUCCESS.message, TIMEOUTS.VERY_LONG);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {

View File

@@ -84,8 +84,7 @@ export default class UserNameDialog extends Vue {
*/
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
// Load from account-specific settings instead of master settings
const settings = await this.$accountSettings();
const settings = await this.$settings();
this.givenName = settings.firstName || "";
this.visible = true;
}
@@ -96,18 +95,7 @@ export default class UserNameDialog extends Vue {
*/
async onClickSaveChanges() {
try {
// Get the current active DID to save to user-specific settings
const settings = await this.$accountSettings();
const activeDid = settings.activeDid;
if (activeDid) {
// Save to user-specific settings for the current identity
await this.$saveUserSettings(activeDid, { firstName: this.givenName });
} else {
// Fallback to master settings if no active DID
await this.$saveSettings({ firstName: this.givenName });
}
await this.$updateSettings({ firstName: this.givenName });
this.visible = false;
this.callback(this.givenName);
} catch (error) {

View File

@@ -1689,11 +1689,3 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = {
title: "They're Added To Your List",
message: "Would you like to go to the main page now?",
};
// ImportAccountView.vue specific constants
// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning)
export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = {
title: "Account Already Imported",
message:
"This account has already been imported. Please use a different seed phrase or check your existing accounts.",
};

View File

@@ -125,9 +125,10 @@ const MIGRATIONS = [
`,
},
{
name: "003_add_hasBackedUpSeed_to_settings",
name: "005_add_starredPlanHandleIds_to_settings",
sql: `
ALTER TABLE settings ADD COLUMN hasBackedUpSeed BOOLEAN DEFAULT FALSE;
ALTER TABLE settings ADD COLUMN starredPlanHandleIds TEXT DEFAULT '[]'; -- JSON string
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
`,
},
];

View File

@@ -157,10 +157,11 @@ export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
result.columns,
result.values,
)[0] as Settings;
if (settings.searchBoxes) {
// @ts-expect-error - the searchBoxes field is a string in the DB
settings.searchBoxes = JSON.parse(settings.searchBoxes);
}
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
settings.starredPlanHandleIds = parseJsonField(
settings.starredPlanHandleIds,
[],
);
return settings;
}
}
@@ -226,10 +227,11 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
);
}
// Handle searchBoxes parsing
if (settings.searchBoxes) {
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
}
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
settings.starredPlanHandleIds = parseJsonField(
settings.starredPlanHandleIds,
[],
);
return settings;
} catch (error) {

View File

@@ -29,7 +29,6 @@ export type Settings = {
finishedOnboarding?: boolean; // the user has completed the onboarding process
firstName?: string; // user's full name, may be null if unwanted for a particular account
hasBackedUpSeed?: boolean; // tracks whether the user has backed up their seed phrase
hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean;
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
@@ -37,6 +36,7 @@ export type Settings = {
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred plan changes that they've acknowledged seeing
// The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string;
@@ -61,15 +61,18 @@ export type Settings = {
showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
starredPlanHandleIds?: string[]; // Array of starred plan handle IDs
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server
webPushServer?: string; // Web Push server URL
};
// type of settings where the searchBoxes are JSON strings instead of objects
// type of settings where the values are JSON strings instead of objects
export type SettingsWithJsonStrings = Settings & {
searchBoxes: string;
starredPlanHandleIds: string;
};
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
@@ -86,6 +89,11 @@ export const SettingsSchema = {
/**
* Constants.
*/
/**
* This is deprecated.
* It only remains for those with a PWA who have not migrated, but we'll soon remove it.
*/
export const MASTER_SETTINGS_KEY = "1";
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

View File

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

View File

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

View File

@@ -56,7 +56,12 @@ import {
KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records";
import {
OfferSummaryRecord,
OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { APP_SERVER } from "@/constants/app";
@@ -362,6 +367,22 @@ export function didInfo(
return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
}
/**
* In some contexts (eg. agent), a blank really is nobody.
*/
export function didInfoOrNobody(
did: string | undefined,
activeDid: string | undefined,
allMyDids: string[],
contacts: Contact[],
): string {
if (did == null) {
return "Nobody";
} else {
return didInfo(did, activeDid, allMyDids, contacts);
}
}
/**
* return text description without any references to "you" as user
*/
@@ -730,7 +751,7 @@ export async function getNewOffersToUser(
activeDid: string,
afterOfferJwtId?: string,
beforeOfferJwtId?: string,
) {
): Promise<{ data: Array<OfferSummaryRecord>; hitLimit: boolean }> {
let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`;
if (afterOfferJwtId) {
url += "&afterId=" + afterOfferJwtId;
@@ -752,7 +773,7 @@ export async function getNewOffersToUserProjects(
activeDid: string,
afterOfferJwtId?: string,
beforeOfferJwtId?: string,
) {
): Promise<{ data: Array<OfferToPlanSummaryRecord>; hitLimit: boolean }> {
let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`;
if (afterOfferJwtId) {
url += "?afterId=" + afterOfferJwtId;
@@ -766,6 +787,44 @@ export async function getNewOffersToUserProjects(
return response.data;
}
/**
* Get starred projects that have been updated since the last check
*
* @param axios - axios instance
* @param apiServer - endorser API server URL
* @param activeDid - user's DID for authentication
* @param starredPlanHandleIds - array of starred project handle IDs
* @param afterId - JWT ID to check for changes after (from lastAckedStarredPlanChangesJwtId)
* @returns { data: Array<PlanSummaryAndPreviousClaim>, hitLimit: boolean }
*/
export async function getStarredProjectsWithChanges(
axios: Axios,
apiServer: string,
activeDid: string,
starredPlanHandleIds: string[],
afterId?: string,
): Promise<{ data: Array<PlanSummaryAndPreviousClaim>; hitLimit: boolean }> {
if (!starredPlanHandleIds || starredPlanHandleIds.length === 0) {
return { data: [], hitLimit: false };
}
if (!afterId) {
return { data: [], hitLimit: false };
}
// Use POST method for larger lists of project IDs
const url = `${apiServer}/api/v2/report/plansLastUpdatedBetween`;
const headers = await getHeaders(activeDid);
const requestBody = {
planIds: starredPlanHandleIds,
afterId: afterId,
};
const response = await axios.post(url, requestBody, { headers });
return response.data;
}
/**
* Construct GiveAction VC for submission to server
*
@@ -1313,28 +1372,6 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
};
/**
* Formats type string for display by adding spaces before capitals
* and optionally adds an appropriate article prefix (a/an)
*
* @param text - Text to format
* @returns Formatted string with article prefix
*/
export const capitalizeAndInsertSpacesBeforeCapsWithAPrefix = (
text: string,
): string => {
const word = capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
}
};
/**
return readable summary of claim, or something generic

View File

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

View File

@@ -3,7 +3,7 @@
import axios, { AxiosResponse } from "axios";
import { Buffer } from "buffer";
import * as R from "ramda";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
import { Account, AccountEncrypted } from "../db/tables/accounts";
@@ -160,41 +160,6 @@ export const isGiveAction = (
return isGiveClaimType(veriClaim.claimType);
};
export interface OfferFulfillment {
offerHandleId: string;
offerType: string;
}
/**
* Extract offer fulfillment information from the fulfills field
* Handles both array and single object cases
*/
export const extractOfferFulfillment = (fulfills: any): OfferFulfillment | null => {
if (!fulfills) {
return null;
}
// Handle both array and single object cases
let offerFulfill = null;
if (Array.isArray(fulfills)) {
// Find the Offer in the fulfills array
offerFulfill = fulfills.find((item) => item["@type"] === "Offer");
} else if (fulfills["@type"] === "Offer") {
// fulfills is a single Offer object
offerFulfill = fulfills;
}
if (offerFulfill) {
return {
offerHandleId: offerFulfill.identifier,
offerType: offerFulfill["@type"],
};
}
return null;
};
export const shortDid = (did: string) => {
if (did.startsWith("did:peer:")) {
return (
@@ -232,19 +197,11 @@ export const nameForContact = (
);
};
export const doCopyTwoSecRedo = async (
text: string,
fn: () => void,
): Promise<void> => {
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
fn();
try {
await copyToClipboard(text);
setTimeout(fn, 2000);
} catch (error) {
// Note: This utility function doesn't have access to notification system
// The calling component should handle error notifications
// Error is silently caught to avoid breaking the 2-second redo pattern
}
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
};
export interface ConfirmerData {
@@ -657,64 +614,57 @@ export const retrieveAllAccountsMetadata = async (): Promise<
return result;
};
export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account.";
/**
* Saves a new identity to SQL database
* Saves a new identity to both SQL and Dexie databases
*/
export async function saveNewIdentity(
identity: IIdentifier,
mnemonic: string,
derivationPath: string,
): Promise<void> {
// add to the new sql db
const platformService = await getPlatformService();
try {
// add to the new sql db
const platformService = await getPlatformService();
// Check if account already exists before attempting to save
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[identity.did],
);
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
if (!secrets?.values?.length || !secrets.values[0]?.length) {
throw new Error(
"No initial encryption supported. We recommend you clear your data and start over.",
);
}
if (existingAccount?.values?.length) {
const secretBase64 = secrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const identityStr = JSON.stringify(identity);
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`;
const params = [
new Date().toISOString(),
derivationPath,
identity.did,
encryptedIdentityBase64,
encryptedMnemonicBase64,
identity.keys[0].publicKeyHex,
];
await platformService.dbExec(sql, params);
await platformService.updateDefaultSettings({ activeDid: identity.did });
await platformService.insertNewDidIntoSettings(identity.did);
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error(
`Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`,
"Failed to set default settings. Please try again or restart the app.",
);
}
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
if (!secrets?.values?.length || !secrets.values[0]?.length) {
throw new Error(
"No initial encryption supported. We recommend you clear your data and start over.",
);
}
const secretBase64 = secrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const identityStr = JSON.stringify(identity);
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`;
const params = [
new Date().toISOString(),
derivationPath,
identity.did,
encryptedIdentityBase64,
encryptedMnemonicBase64,
identity.keys[0].publicKeyHex,
];
await platformService.dbExec(sql, params);
await platformService.updateDefaultSettings({ activeDid: identity.did });
await platformService.insertNewDidIntoSettings(identity.did);
}
/**
@@ -1082,58 +1032,3 @@ export async function importFromMnemonic(
}
}
}
/**
* Checks if an account with the given DID already exists in the database
*
* @param did - The DID to check for duplicates
* @returns Promise<boolean> - True if account already exists, false otherwise
* @throws Error if database query fails
*/
export async function checkForDuplicateAccount(did: string): Promise<boolean>;
/**
* Checks if an account with the given DID already exists in the database
*
* @param mnemonic - The mnemonic phrase to derive DID from
* @param derivationPath - The derivation path to use
* @returns Promise<boolean> - True if account already exists, false otherwise
* @throws Error if database query fails
*/
export async function checkForDuplicateAccount(
mnemonic: string,
derivationPath: string,
): Promise<boolean>;
/**
* Implementation of checkForDuplicateAccount with overloaded signatures
*/
export async function checkForDuplicateAccount(
didOrMnemonic: string,
derivationPath?: string,
): Promise<boolean> {
let didToCheck: string;
if (derivationPath) {
// Derive the DID from mnemonic and derivation path
const [address, privateHex, publicHex] = deriveAddress(
didOrMnemonic.trim().toLowerCase(),
derivationPath,
);
const newId = newIdentifier(address, privateHex, publicHex, derivationPath);
didToCheck = newId.did;
} else {
// Use the provided DID directly
didToCheck = didOrMnemonic;
}
// Check if an account with this DID already exists
const platformService = await getPlatformService();
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[didToCheck],
);
return (existingAccount?.values?.length ?? 0) > 0;
}

View File

@@ -36,7 +36,7 @@ export class WebInlineQRScanner implements QRScannerService {
// Generate a short random ID for this scanner instance
this.id = Math.random().toString(36).substring(2, 8).toUpperCase();
this.options = options ?? {};
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`,
{
...this.options,
@@ -49,7 +49,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.context = this.canvas.getContext("2d", { willReadFrequently: true });
this.video = document.createElement("video");
this.video.setAttribute("playsinline", "true"); // Required for iOS
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] DOM elements created successfully`,
);
}
@@ -60,7 +60,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.cameraStateListeners.forEach((listener) => {
try {
listener.onStateChange(state, message);
logger.debug(
logger.info(
`[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`,
{
state,
@@ -89,7 +89,7 @@ export class WebInlineQRScanner implements QRScannerService {
async checkPermissions(): Promise<boolean> {
try {
this.updateCameraState("initializing", "Checking camera permissions...");
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`,
);
@@ -99,7 +99,7 @@ export class WebInlineQRScanner implements QRScannerService {
const permissions = await navigator.permissions.query({
name: "camera" as PermissionName,
});
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`,
permissions.state,
);
@@ -165,7 +165,7 @@ export class WebInlineQRScanner implements QRScannerService {
"initializing",
"Requesting camera permissions...",
);
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera permissions...`,
);
@@ -175,7 +175,7 @@ export class WebInlineQRScanner implements QRScannerService {
(device) => device.kind === "videoinput",
);
logger.debug(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
count: videoDevices.length,
devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })),
userAgent: navigator.userAgent,
@@ -188,7 +188,7 @@ export class WebInlineQRScanner implements QRScannerService {
}
// Try to get a stream with specific constraints
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera stream with constraints:`,
{
facingMode: "environment",
@@ -210,7 +210,7 @@ export class WebInlineQRScanner implements QRScannerService {
// Stop the test stream immediately
stream.getTracks().forEach((track) => {
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
kind: track.kind,
label: track.label,
readyState: track.readyState,
@@ -275,12 +275,12 @@ export class WebInlineQRScanner implements QRScannerService {
async isSupported(): Promise<boolean> {
try {
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Checking browser support...`,
);
// Check for secure context first
if (!window.isSecureContext) {
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Camera access requires HTTPS (secure context)`,
);
return false;
@@ -300,7 +300,7 @@ export class WebInlineQRScanner implements QRScannerService {
(device) => device.kind === "videoinput",
);
logger.debug(`[WebInlineQRScanner:${this.id}] Device support check:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, {
hasSecureContext: window.isSecureContext,
hasMediaDevices: !!navigator.mediaDevices,
hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia,
@@ -379,7 +379,7 @@ export class WebInlineQRScanner implements QRScannerService {
// Log scan attempt every 100 frames or 1 second
if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) {
logger.debug(`[WebInlineQRScanner:${this.id}] Scanning frame:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, {
attempt: this.scanAttempts,
dimensions: {
width: this.canvas.width,
@@ -421,7 +421,7 @@ export class WebInlineQRScanner implements QRScannerService {
!code.data ||
code.data.length === 0;
logger.debug(`[WebInlineQRScanner:${this.id}] QR Code detected:`, {
logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, {
data: code.data,
location: code.location,
attempts: this.scanAttempts,
@@ -512,13 +512,13 @@ export class WebInlineQRScanner implements QRScannerService {
this.scanAttempts = 0;
this.lastScanTime = Date.now();
this.updateCameraState("initializing", "Starting camera...");
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Starting scan with options:`,
this.options,
);
// Get camera stream with options
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`,
);
this.stream = await navigator.mediaDevices.getUserMedia({
@@ -531,7 +531,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.updateCameraState("active", "Camera is active");
logger.debug(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
tracks: this.stream.getTracks().map((t) => ({
kind: t.kind,
label: t.label,
@@ -550,14 +550,14 @@ export class WebInlineQRScanner implements QRScannerService {
this.video.style.display = "none";
}
await this.video.play();
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Video element started playing`,
);
}
// Emit stream to component
this.events.emit("stream", this.stream);
logger.debug(`[WebInlineQRScanner:${this.id}] Stream event emitted`);
logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`);
// Start QR code scanning
this.scanQRCode();
@@ -595,7 +595,7 @@ export class WebInlineQRScanner implements QRScannerService {
}
try {
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping scan`, {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, {
scanAttempts: this.scanAttempts,
duration: Date.now() - this.lastScanTime,
});
@@ -604,7 +604,7 @@ export class WebInlineQRScanner implements QRScannerService {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Animation frame cancelled`,
);
}
@@ -613,13 +613,13 @@ export class WebInlineQRScanner implements QRScannerService {
if (this.video) {
this.video.pause();
this.video.srcObject = null;
logger.debug(`[WebInlineQRScanner:${this.id}] Video element stopped`);
logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`);
}
// Stop all tracks in the stream
if (this.stream) {
this.stream.getTracks().forEach((track) => {
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping track:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, {
kind: track.kind,
label: track.label,
readyState: track.readyState,
@@ -631,7 +631,7 @@ export class WebInlineQRScanner implements QRScannerService {
// Emit stream stopped event
this.events.emit("stream", null);
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`,
);
} catch (error) {
@@ -643,17 +643,17 @@ export class WebInlineQRScanner implements QRScannerService {
throw error;
} finally {
this.isScanning = false;
logger.debug(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
}
}
addListener(listener: ScanListener): void {
logger.debug(`[WebInlineQRScanner:${this.id}] Adding scan listener`);
logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`);
this.scanListener = listener;
}
onStream(callback: (stream: MediaStream | null) => void): void {
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Adding stream event listener`,
);
this.events.on("stream", callback);
@@ -661,24 +661,24 @@ export class WebInlineQRScanner implements QRScannerService {
async cleanup(): Promise<void> {
try {
logger.debug(`[WebInlineQRScanner:${this.id}] Starting cleanup`);
logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`);
await this.stopScan();
this.events.removeAllListeners();
logger.debug(`[WebInlineQRScanner:${this.id}] Event listeners removed`);
logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`);
// Clean up DOM elements
if (this.video) {
this.video.remove();
this.video = null;
logger.debug(`[WebInlineQRScanner:${this.id}] Video element removed`);
logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`);
}
if (this.canvas) {
this.canvas.remove();
this.canvas = null;
logger.debug(`[WebInlineQRScanner:${this.id}] Canvas element removed`);
logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`);
}
this.context = null;
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`,
);
} catch (error) {

View File

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

View File

@@ -249,13 +249,13 @@ export const PlatformServiceMixin = {
// Keep null values as null
}
// Handle JSON fields like contactMethods
if (column === "contactMethods" && typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
value = [];
}
// Convert SQLite JSON strings to objects/arrays
if (
column === "contactMethods" ||
column === "searchBoxes" ||
column === "starredPlanHandleIds"
) {
value = this._parseJsonField(value, []);
}
obj[column] = value;

View File

@@ -1,90 +0,0 @@
import { NotificationIface } from "@/constants/app";
const SEED_REMINDER_KEY = "seedPhraseReminderLastShown";
const REMINDER_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
/**
* Checks if the seed phrase backup reminder should be shown
* @param hasBackedUpSeed - Whether the user has backed up their seed phrase
* @returns true if the reminder should be shown, false otherwise
*/
export function shouldShowSeedReminder(hasBackedUpSeed: boolean): boolean {
// Don't show if user has already backed up
if (hasBackedUpSeed) {
return false;
}
// Check localStorage for last shown time
const lastShown = localStorage.getItem(SEED_REMINDER_KEY);
if (!lastShown) {
return true; // First time, show the reminder
}
try {
const lastShownTime = parseInt(lastShown, 10);
const now = Date.now();
const timeSinceLastShown = now - lastShownTime;
// Show if more than 24 hours have passed
return timeSinceLastShown >= REMINDER_COOLDOWN_MS;
} catch (error) {
// If there's an error parsing the timestamp, show the reminder
return true;
}
}
/**
* Marks the seed phrase reminder as shown by updating localStorage
*/
export function markSeedReminderShown(): void {
localStorage.setItem(SEED_REMINDER_KEY, Date.now().toString());
}
/**
* Creates the seed phrase backup reminder notification
* @returns NotificationIface configuration for the reminder modal
*/
export function createSeedReminderNotification(): NotificationIface {
return {
group: "modal",
type: "confirm",
title: "Backup Your Identifier Seed?",
text: "It looks like you haven't backed up your identifier seed yet. It's important to back it up as soon as possible to secure your identity.",
yesText: "Backup Identifier Seed",
noText: "Remind me Later",
onYes: async () => {
// Navigate to seed backup page
window.location.href = "/seed-backup";
},
onNo: async () => {
// Mark as shown so it won't appear again for 24 hours
markSeedReminderShown();
},
onCancel: async () => {
// Mark as shown so it won't appear again for 24 hours
markSeedReminderShown();
},
};
}
/**
* Shows the seed phrase backup reminder if conditions are met
* @param hasBackedUpSeed - Whether the user has backed up their seed phrase
* @param notifyFunction - Function to show notifications
* @returns true if the reminder was shown, false otherwise
*/
export function showSeedPhraseReminder(
hasBackedUpSeed: boolean,
notifyFunction: (notification: NotificationIface, timeout?: number) => void,
): boolean {
if (shouldShowSeedReminder(hasBackedUpSeed)) {
const notification = createSeedReminderNotification();
// Add 1-second delay before showing the modal to allow success message to be visible
setTimeout(() => {
// Pass -1 as timeout to ensure modal stays open until user interaction
notifyFunction(notification, -1);
}, 1000);
return true;
}
return false;
}

View File

@@ -150,8 +150,6 @@
</section>
<PushNotificationPermission ref="pushNotificationPermission" />
<LocationSearchSection :search-box="searchBox" />
<!-- User Profile -->
<section
v-if="isRegistered"
@@ -244,6 +242,8 @@
<div v-else>Saving...</div>
</section>
<LocationSearchSection :search-box="searchBox" />
<UsageLimitsSection
v-if="activeDid"
:loading-limits="loadingLimits"
@@ -764,7 +764,7 @@ import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { Capacitor } from "@capacitor/core";
@@ -811,7 +811,6 @@ import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
AccountSettings,
isApiError,
@@ -1060,8 +1059,8 @@ export default class AccountViewView extends Vue {
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered;
this.isSearchAreasSet = !!settings.searchBoxes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.isSearchAreasSet =
!!settings.searchBoxes && settings.searchBoxes.length > 0;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.notifyingReminder = !!settings.notifyingReminderTime;
@@ -1075,6 +1074,7 @@ export default class AccountViewView extends Vue {
this.passkeyExpirationMinutes =
settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES;
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.showShortcutBvc = !!settings.showShortcutBvc;
this.warnIfProdServer = !!settings.warnIfProdServer;
@@ -1084,15 +1084,11 @@ export default class AccountViewView extends Vue {
}
// call fn, copy text to the clipboard, then redo fn after 2 seconds
async doCopyTwoSecRedo(text: string, fn: () => void): Promise<void> {
doCopyTwoSecRedo(text: string, fn: () => void): void {
fn();
try {
await copyToClipboard(text);
setTimeout(fn, 2000);
} catch (error) {
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
this.notify.error("Failed to copy to clipboard.");
}
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
async toggleShowContactAmounts(): Promise<void> {
@@ -1700,14 +1696,6 @@ export default class AccountViewView extends Vue {
);
if (success) {
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
} else {
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
}

View File

@@ -41,7 +41,6 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
// Type guard for API responses
function isApiResponse(response: unknown): response is AxiosResponse {
@@ -224,14 +223,6 @@ export default class ClaimAddRawView extends Vue {
);
if (result.success) {
this.notify.success("Claim submitted.", TIMEOUTS.LONG);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
} else {
logger.error("Got error submitting the claim:", result);
this.notify.error(

View File

@@ -24,9 +24,7 @@
<div class="flex columns-3">
<h2 class="text-md font-bold w-full">
{{
serverUtil.capitalizeAndInsertSpacesBeforeCaps(
veriClaim.claimType || "",
)
capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType || "")
}}
<button
v-if="canEditClaim"
@@ -58,7 +56,7 @@
title="Copy Printable Certificate Link"
aria-label="Copy printable certificate link"
@click="
copyTextToClipboard(
copyToClipboard(
'A link to the certificate page',
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
)
@@ -72,9 +70,7 @@
<button
title="Copy Link"
aria-label="Copy page link"
@click="
copyTextToClipboard('A link to this page', windowDeepLink)
"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>
<font-awesome icon="link" class="text-slate-500" />
</button>
@@ -83,7 +79,10 @@
<div class="text-sm">
<div data-testId="description">
<font-awesome icon="message" class="fa-fw text-slate-400" />
{{ claimDescription }}
<vue-markdown
:source="claimDescription"
class="markdown-content"
/>
</div>
<div>
<font-awesome icon="user" class="fa-fw text-slate-400" />
@@ -110,91 +109,77 @@
</div>
<!-- Fullfills Links -->
<div class="mt-4 empty:hidden">
<!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForGive?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2"
>
This fulfills a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- Show offer fulfillment if this give fulfills an offer -->
<div v-if="detailsForGiveOfferFulfillment?.offerHandleId">
<!-- router-link to /claim/ only changes URL path -->
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="
showDifferentClaimPage(
detailsForGiveOfferFulfillment.offerHandleId,
)
"
>
This fulfills
{{
serverUtil.capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
detailsForGiveOfferFulfillment.offerType || "Offer",
)
}}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</div>
<!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId" class="mt-4">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForGive?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2"
>
Fulfills a bigger plan...
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
detailsForGive?.fulfillsType &&
detailsForGive?.fulfillsType !== 'PlanAction' &&
detailsForGive?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="
showDifferentClaimPage(detailsForGive?.fulfillsHandleId)
"
>
Fulfills
{{
capitalizeAndInsertSpacesBeforeCaps(
detailsForGive.fulfillsType,
)
}}...
</a>
</div>
<!-- fullfills links for an offer -->
<div v-if="detailsForOffer?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForOffer?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-4"
>
Offered to a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- fullfills links for an offer -->
<div v-if="detailsForOffer?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForOffer?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-4"
>
Offered to a bigger plan...
</router-link>
</div>
<!-- Providers -->
<div v-if="providersForGive?.length > 0">
<span>Other assistance provided by:</span>
<ul class="ml-4">
<li
v-for="provider of providersForGive"
:key="provider.identifier"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="handleProviderClick(provider)"
>
an activity
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</div>
<!-- Providers -->
<div v-if="providersForGive?.length > 0" class="mt-4">
<span>Other assistance provided by:</span>
<ul class="ml-4">
<li
v-for="provider of providersForGive"
:key="provider.identifier"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="handleProviderClick(provider)"
>
an activity...
</a>
</div>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
@@ -401,7 +386,7 @@
contacts can see more details:
<a
class="text-blue-500"
@click="copyTextToClipboard('A link to this page', windowDeepLink)"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -424,7 +409,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyTextToClipboard('A link to this page', windowDeepLink)"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -533,8 +518,10 @@ import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
@@ -553,7 +540,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { APP_SERVER } from "@/constants/app";
@Component({
components: { GiftedDialog, QuickNav },
components: { GiftedDialog, QuickNav, VueMarkdown },
mixins: [PlatformServiceMixin],
})
export default class ClaimView extends Vue {
@@ -574,17 +561,6 @@ export default class ClaimView extends Vue {
fulfillsPlanHandleId?: string;
fulfillsType?: string;
fulfillsHandleId?: string;
fullClaim?: {
fulfills?: Array<{
"@type": string;
identifier?: string;
}>;
};
} | null = null;
// Additional offer information extracted from the fulfills array
detailsForGiveOfferFulfillment: {
offerHandleId?: string;
offerType?: string;
} | null = null;
detailsForOffer: { fulfillsPlanHandleId?: string } | null = null;
// Project information for fulfillsPlanHandleId
@@ -718,7 +694,6 @@ export default class ClaimView extends Vue {
this.confsVisibleToIdList = [];
this.detailsForGive = null;
this.detailsForOffer = null;
this.detailsForGiveOfferFulfillment = null;
this.projectInfo = null;
this.fullClaim = null;
this.fullClaimDump = "";
@@ -731,15 +706,6 @@ export default class ClaimView extends Vue {
this.veriClaimDidsVisible = {};
}
/**
* Extract offer fulfillment information from the fulfills array
*/
extractOfferFulfillment() {
this.detailsForGiveOfferFulfillment = libsUtil.extractOfferFulfillment(
this.detailsForGive?.fullClaim?.fulfills
);
}
// =================================================
// UTILITY METHODS
// =================================================
@@ -797,6 +763,13 @@ export default class ClaimView extends Vue {
this.canShare = !!navigator.share;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
capitalizeAndInsertSpacesBeforeCaps(text: string): string {
if (!text) return "";
return text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
totalConfirmers() {
return (
this.numConfsNotVisible +
@@ -853,8 +826,6 @@ export default class ClaimView extends Vue {
});
if (giveResp.status === 200 && giveResp.data.data?.length > 0) {
this.detailsForGive = giveResp.data.data[0];
// Extract offer information from the fulfills array
this.extractOfferFulfillment();
} else {
await this.$logError(
"Error getting detailed give info: " + JSON.stringify(giveResp),
@@ -1131,21 +1102,16 @@ export default class ClaimView extends Vue {
);
}
async copyTextToClipboard(name: string, text: string) {
try {
await copyToClipboard(text);
this.notify.copied(name || "That");
} catch (error) {
this.$logAndConsole(
`Error copying ${name || "content"} to clipboard: ${error}`,
true,
);
this.notify.error(`Failed to copy ${name || "content"} to clipboard.`);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.notify.copied(name || "That");
});
}
onClickShareClaim() {
this.copyTextToClipboard("A link to this page", this.windowDeepLink);
this.copyToClipboard("A link to this page", this.windowDeepLink);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",

View File

@@ -96,50 +96,50 @@
</div>
<!-- Fullfills Links -->
<div class="mt-4">
<!-- fullfills links for a give -->
<div v-if="giveDetails?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(
giveDetails?.fulfillsPlanHandleId || '',
)
"
class="text-blue-500 mt-2 cursor-pointer"
>
This fulfills a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- Show offer fulfillment if this give fulfills an offer -->
<div v-if="giveDetailsOfferFulfillment?.offerHandleId">
<!-- router-link to /claim/ only changes URL path -->
<router-link
:to="
'/claim/' +
encodeURIComponent(
giveDetailsOfferFulfillment.offerHandleId || '',
)
"
class="text-blue-500 mt-2 cursor-pointer"
>
This fulfills
{{
serverUtil.capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetailsOfferFulfillment.offerType || "Offer",
)
}}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- fullfills links for a give -->
<div v-if="giveDetails?.fulfillsPlanHandleId" class="mt-2">
<router-link
:to="
'/project/' +
encodeURIComponent(giveDetails?.fulfillsPlanHandleId || '')
"
class="text-blue-500 mt-2 cursor-pointer"
>
This fulfills a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
giveDetails?.fulfillsType &&
giveDetails?.fulfillsType !== 'PlanAction' &&
giveDetails?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<router-link
:to="
'/claim/' +
encodeURIComponent(giveDetails?.fulfillsHandleId || '')
"
class="text-blue-500 mt-2 cursor-pointer"
>
This fulfills
{{
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetails?.fulfillsType || "",
)
}}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
</div>
</div>
@@ -192,7 +192,7 @@
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyTextToClipboard(
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
@@ -238,7 +238,7 @@
>
<button
@click="
copyTextToClipboard(
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
@@ -309,9 +309,7 @@
contacts can see more details:
<a
class="text-blue-500"
@click="
copyTextToClipboard('A link to this page', windowLocation)
"
@click="copyToClipboard('A link to this page', windowLocation)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -334,9 +332,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="
copyTextToClipboard('A link to this page', windowLocation)
"
@click="copyToClipboard('A link to this page', windowLocation)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -364,7 +360,7 @@
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="
copyTextToClipboard('The DID of ' + visDid, visDid)
copyToClipboard('The DID of ' + visDid, visDid)
"
>
<font-awesome
@@ -437,7 +433,7 @@
import * as yaml from "js-yaml";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -497,11 +493,6 @@ export default class ConfirmGiftView extends Vue {
confsVisibleErrorMessage = "";
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
giveDetails?: GiveSummaryRecord;
// Additional offer information extracted from the fulfills array
giveDetailsOfferFulfillment: {
offerHandleId?: string;
offerType?: string;
} | null = null;
giverName = "";
issuerName = "";
isLoading = false;
@@ -657,8 +648,6 @@ export default class ConfirmGiftView extends Vue {
if (resp.status === 200) {
this.giveDetails = resp.data.data[0];
// Extract offer information from the fulfills array
this.extractOfferFulfillment();
} else {
throw new Error("Error getting detailed give info: " + resp.status);
}
@@ -718,15 +707,6 @@ export default class ConfirmGiftView extends Vue {
}
}
/**
* Extract offer fulfillment information from the fulfills array
*/
private extractOfferFulfillment() {
this.giveDetailsOfferFulfillment = libsUtil.extractOfferFulfillment(
this.giveDetails?.fullClaim?.fulfills
);
}
/**
* Fetches confirmer information for the claim
*/
@@ -783,21 +763,16 @@ export default class ConfirmGiftView extends Vue {
* @param description - Description of copied content
* @param text - Text to copy
*/
async copyTextToClipboard(description: string, text: string): Promise<void> {
try {
await copyToClipboard(text);
this.notify.toast(
NOTIFY_COPIED_TO_CLIPBOARD.title,
NOTIFY_COPIED_TO_CLIPBOARD.message(description),
TIMEOUTS.SHORT,
);
} catch (error) {
this.$logAndConsole(
`Error copying ${description} to clipboard: ${error}`,
true,
);
this.notify.error(`Failed to copy ${description} to clipboard.`);
}
copyToClipboard(description: string, text: string): void {
useClipboard()
.copy(text)
.then(() => {
this.notify.toast(
NOTIFY_COPIED_TO_CLIPBOARD.title,
NOTIFY_COPIED_TO_CLIPBOARD.message(description),
TIMEOUTS.SHORT,
);
});
}
/**
@@ -874,12 +849,33 @@ export default class ConfirmGiftView extends Vue {
);
}
/**
* Formats type string for display by adding spaces before capitals
* Optionally adds a prefix
*
* @param text - Text to format
* @param prefix - Optional prefix to add
* @returns Formatted string
*/
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string): string {
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
}
}
/**
* Initiates sharing of claim information
* Handles share functionality based on platform capabilities
*/
async onClickShareClaim(): Promise<void> {
this.copyTextToClipboard("A link to this page", this.windowLocation);
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
@@ -898,5 +894,11 @@ export default class ConfirmGiftView extends Vue {
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
}
</script>

View File

@@ -104,7 +104,7 @@ import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { logger } from "../utils/logger";
import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory";
@@ -144,7 +144,6 @@ import {
QR_TIMEOUT_LONG,
} from "@/constants/notifications";
import { createNotifyHelpers, NotifyFunction } from "../utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
interface QRScanResult {
rawValue?: string;
@@ -196,7 +195,7 @@ export default class ContactQRScanFull extends Vue {
$router!: Router;
// Notification helper system
private notify!: ReturnType<typeof createNotifyHelpers>;
private notify = createNotifyHelpers(this.$notify);
isScanning = false;
error: string | null = null;
@@ -264,9 +263,6 @@ export default class ContactQRScanFull extends Vue {
* Loads user settings and generates QR code for contact sharing
*/
async created() {
// Initialize notification helper system
this.notify = createNotifyHelpers(this.$notify);
try {
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
@@ -626,15 +622,6 @@ export default class ContactQRScanFull extends Vue {
*/
async handleBack() {
await this.cleanupScanner();
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
this.$router.back();
}
@@ -649,51 +636,36 @@ export default class ContactQRScanFull extends Vue {
* Copies contact URL to clipboard for sharing
*/
async onCopyUrlToClipboard() {
try {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
// Use the platform-specific ClipboardService for reliable iOS support
await copyToClipboard(jwtUrl);
this.notify.toast(
NOTIFY_QR_URL_COPIED.title,
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
} catch (error) {
logger.error("Error copying URL to clipboard:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard()
.copy(jwtUrl)
.then(() => {
this.notify.toast(
NOTIFY_QR_URL_COPIED.title,
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
});
this.notify.error("Failed to copy URL to clipboard.");
}
}
/**
* Copies DID to clipboard for manual sharing
*/
async onCopyDidToClipboard() {
try {
// Use the platform-specific ClipboardService for reliable iOS support
await copyToClipboard(this.activeDid);
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
} catch (error) {
logger.error("Error copying DID to clipboard:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
onCopyDidToClipboard() {
useClipboard()
.copy(this.activeDid)
.then(() => {
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
});
this.notify.error("Failed to copy DID to clipboard.");
}
}
/**

View File

@@ -140,7 +140,6 @@ import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { QrcodeStream } from "vue-qrcode-reader";
@@ -164,7 +163,6 @@ import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_QR_INITIALIZATION_ERROR,
NOTIFY_QR_CAMERA_IN_USE,
@@ -321,15 +319,6 @@ export default class ContactQRScanShow extends Vue {
async handleBack(): Promise<void> {
await this.cleanupScanner();
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
this.$router.back();
}
@@ -629,6 +618,7 @@ export default class ContactQRScanShow extends Vue {
);
// Copy the URL to clipboard
const { copyToClipboard } = await import("../services/ClipboardService");
await copyToClipboard(jwtUrl);
this.notify.toast(
NOTIFY_QR_URL_COPIED.title,
@@ -647,6 +637,7 @@ export default class ContactQRScanShow extends Vue {
async onCopyDidToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
try {
const { copyToClipboard } = await import("../services/ClipboardService");
await copyToClipboard(this.activeDid);
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
} catch (error) {
@@ -747,17 +738,24 @@ export default class ContactQRScanShow extends Vue {
!contact.registered
) {
setTimeout(() => {
this.$notify(
this.notify.confirm(
"Do you want to register them?",
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
if (stopAsking) {
await this.$updateSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
if (stopAsking) {
await this.$updateSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(contact);
@@ -887,17 +885,6 @@ export default class ContactQRScanShow extends Vue {
videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none";
}
}
private async handleRegistrationPromptResponse(
stopAsking?: boolean,
): Promise<void> {
if (stopAsking) {
await this.$saveSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
}
}
</script>

View File

@@ -71,22 +71,22 @@
contactFromDid?.seesMe && contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They can see your activity"
title="They can see you"
@click="confirmSetVisibility(contactFromDid, false)"
>
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They cannot see your activity"
title="They cannot see you"
@click="confirmSetVisibility(contactFromDid, true)"
>
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
@@ -95,11 +95,11 @@
contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You watch their activity"
title="I view their content"
@click="confirmViewContent(contactFromDid, false)"
>
<font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
v-else-if="
@@ -107,11 +107,11 @@
contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You do not watch their activity"
title="I do not view their content"
@click="confirmViewContent(contactFromDid, true)"
>
<font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button

View File

@@ -1003,7 +1003,7 @@
<h2>Exported Data</h2>
<span
class="text-blue-500 cursor-pointer hover:text-blue-700"
@click="copyExportedDataToClipboard"
@click="copyToClipboard"
>
Copy to Clipboard
</span>
@@ -1014,7 +1014,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router";
import {
@@ -1072,6 +1072,8 @@ export default class DatabaseMigration extends Vue {
private exportedData: Record<string, any> | null = null;
private successMessage = "";
useClipboard = useClipboard;
/**
* Computed property to get the display name for a setting
* Handles both live comparison data and exported JSON format
@@ -1131,11 +1133,13 @@ export default class DatabaseMigration extends Vue {
/**
* Copies exported data to clipboard and shows success message
*/
async copyExportedDataToClipboard(): Promise<void> {
async copyToClipboard(): Promise<void> {
if (!this.exportedData) return;
try {
await copyToClipboard(JSON.stringify(this.exportedData, null, 2));
await this.useClipboard().copy(
JSON.stringify(this.exportedData, null, 2),
);
// Use global window object properly
if (typeof window !== "undefined") {
window.alert("Copied to clipboard!");

View File

@@ -51,6 +51,33 @@
<!-- Secondary Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li v-if="isProjectsActive">
<a
href="#"
:class="computedStarredTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
isStarredActive = true;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = false;
isSearchVisible = false;
tempSearchBox = null;
searchStarred();
"
>
Starred
<!-- restore when the links don't jump around for different numbers
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
v-if="isLocalActive"
>
{{ localCount > -1 ? localCount : "?" }}
</span>
-->
</a>
</li>
<li>
<a
href="#"
@@ -58,9 +85,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = true;
isMappedActive = false;
isAnywhereActive = false;
isStarredActive = false;
isSearchVisible = true;
tempSearchBox = null;
searchLocal();
@@ -84,9 +113,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = false;
isMappedActive = true;
isAnywhereActive = false;
isStarredActive = false;
isSearchVisible = false;
searchTerms = '';
tempSearchBox = null;
@@ -103,9 +134,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = true;
isStarredActive = false;
isSearchVisible = true;
tempSearchBox = null;
searchAll();
@@ -201,6 +234,15 @@
>No {{ isProjectsActive ? "projects" : "people" }} were found with
that search.</span
>
<span v-else-if="isStarredActive">
<p>
You have no starred projects. Star some projects to see them here.
</p>
<p class="mt-4">
When you star projects, you will get a notice on the front page when
they change.
</p>
</span>
</p>
</div>
@@ -383,9 +425,12 @@ export default class DiscoverView extends Vue {
allMyDids: Array<string> = [];
apiServer = "";
isLoading = false;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = true;
isStarredActive = false;
isProjectsActive = true;
isPeopleActive = false;
isSearchVisible = true;
@@ -470,6 +515,8 @@ export default class DiscoverView extends Vue {
leafletObject: L.Map;
};
this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation
} else if (this.isStarredActive) {
await this.searchStarred();
} else {
await this.searchAll();
}
@@ -540,6 +587,60 @@ export default class DiscoverView extends Vue {
}
}
public async searchStarred() {
this.resetCounts();
// Clear any previous results
this.projects = [];
this.userProfiles = [];
try {
this.isLoading = true;
// Get starred project IDs from settings
const settings = await this.$accountSettings();
const starredIds = settings.starredPlanHandleIds || [];
if (starredIds.length === 0) {
// No starred projects
return;
}
// This could be optimized to only pull those not already in the cache (endorserServer.ts)
const planHandleIdsJson = JSON.stringify(starredIds);
const endpoint =
this.apiServer +
"/api/v2/report/plans?planHandleIds=" +
encodeURIComponent(planHandleIdsJson);
const response = await this.axios.get(endpoint, {
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
this.notify.error("Failed to load starred projects", TIMEOUTS.SHORT);
return;
}
const starredPlans: PlanData[] = response.data.data;
if (response.data.hitLimit) {
// someday we'll have to let them incrementally load the rest
this.notify.warning(
"Beware: you have so many starred projects that we cannot load them all.",
TIMEOUTS.SHORT,
);
}
this.projects = starredPlans;
} catch (error: unknown) {
logger.error("Error loading starred projects:", error);
this.notify.error(
"Failed to load starred projects. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.isLoading = false;
}
}
public async searchLocal(beforeId?: string) {
this.resetCounts();
@@ -633,9 +734,12 @@ export default class DiscoverView extends Vue {
const latestProject = this.projects[this.projects.length - 1];
if (this.isLocalActive || this.isMappedActive) {
this.searchLocal(latestProject.rowId);
} else if (this.isStarredActive) {
this.searchStarred();
} else if (this.isAnywhereActive) {
this.searchAll(latestProject.rowId);
}
// Note: Starred tab doesn't support pagination since we load all starred projects at once
} else if (this.isPeopleActive && this.userProfiles.length > 0) {
const latestProfile = this.userProfiles[this.userProfiles.length - 1];
if (this.isLocalActive || this.isMappedActive) {
@@ -775,6 +879,24 @@ export default class DiscoverView extends Vue {
this.$router.push(route);
}
public computedStarredTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isStarredActive,
"text-black": this.isStarredActive,
"border-black": this.isStarredActive,
"font-semibold": this.isStarredActive,
"text-blue-600": !this.isStarredActive,
"border-transparent": !this.isStarredActive,
"hover:border-slate-400": !this.isStarredActive,
};
}
public computedLocalTabStyleClassNames() {
return {
"inline-block": true,

View File

@@ -280,7 +280,6 @@ import { logger } from "../utils/logger";
import { Contact } from "@/db/tables/contacts";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
@@ -771,15 +770,6 @@ export default class GiftedDetails extends Vue {
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message,
TIMEOUTS.SHORT,
);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });

View File

@@ -319,9 +319,8 @@
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions to "Import Contacts".
(There is currently no way to import other settings, so you'll have to recreate
by hand your search area, filters, etc.)
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
Beware that this will erase your existing contact & settings.
</li>
</ul>
</div>
@@ -337,18 +336,14 @@
<h2 class="text-xl font-semibold">How do I erase my data from my device?</h2>
<p>
Before doing this, you should back up your data with the instructions above.
Note that this does not erase data sent to our servers (see contact info below)
Before doing this, you may want to back up your data with the instructions above.
</p>
<ul>
<li class="list-disc list-outside ml-4">
Mobile
<ul>
<li class="list-disc list-outside ml-4">
App Store app: hold down on the icon, then uninstall it
</li>
<li class="list-disc list-outside ml-4">
Home Screen PWA: hold down on the icon, and delete it
Home Screen: hold down on the icon, and choose to delete it
</li>
<li class="list-disc list-outside ml-4">
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
@@ -420,6 +415,15 @@
different page.
</p>
<h2 class="text-xl font-semibold">
Where do I get help with notifications?
</h2>
<p>
<router-link class="text-blue-500" to="/help-notifications"
>Here.</router-link
>
</p>
<h2 class="text-xl font-semibold">
This app is misbehaving, like showing me a blank screen or failing to show my personal data.
What can I do?
@@ -430,13 +434,10 @@
</p>
<ul class="list-disc list-outside ml-4">
<li>
For mobile apps, make sure you're connected to the internet.
</li>
<li>
For PWAs, drag down on the screen to refresh it; do that multiple times, because
Drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the latest version.
You can see the version information at the bottom of this page; the best
way to determine the latest version is to open TimeSafari.app in an incognito/private
way to determine the latest version is to open this page in an incognito/private
browser window and look at the version there.
</li>
<li>
@@ -467,6 +468,9 @@
</ul>
Then reload Time Safari.
</li>
<li>
Restart your device.
</li>
</ul>
<p>
If you still have problems, you can clear the cache (see "erase my data" above)
@@ -504,12 +508,16 @@
</p>
<ul class="list-disc list-outside ml-4">
<li>
If sending images, a server stores them. They can be removed by editing each claim
and deleting the image.
If using notifications, a server stores push token data. That can be revoked at any time
by disabling notifications on the Profile <font-awesome icon="circle-user" class="fa-fw" /> page.
</li>
<li>
If sending images, a server stores them, too. They can be removed by editing the claim
and deleting them.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key and message
data are stored on a server. Those can be removed via direct personal request (via contact below).
data are stored on a server. Those can be removed via direct personal request.
</li>
<li>
For all other claim data,
@@ -584,16 +592,15 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
// Capacitor import removed - using QRNavigationService instead
import * as Package from "../../package.json";
import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER, NotificationIface } from "../constants/app";
import { APP_SERVER } from "../constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { QRNavigationService } from "@/services/QRNavigationService";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
/**
* HelpView.vue - Comprehensive Help System Component
@@ -627,10 +634,8 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
})
export default class HelpView extends Vue {
$router!: Router;
$notify!: (notification: NotificationIface, timeout?: number) => void;
package = Package;
notify!: ReturnType<typeof createNotifyHelpers>;
commitHash = import.meta.env.VITE_GIT_HASH;
showAlpha = false;
showBasics = false;
@@ -643,13 +648,6 @@ export default class HelpView extends Vue {
APP_SERVER = APP_SERVER;
// Capacitor reference removed - using QRNavigationService instead
/**
* Initialize notification helpers
*/
created() {
this.notify = createNotifyHelpers(this.$notify);
}
/**
* Get the unnamed entity name constant
*/
@@ -670,15 +668,11 @@ export default class HelpView extends Vue {
* @param {string} text - The text to copy to clipboard
* @param {Function} fn - Callback function to execute before and after copying
*/
async doCopyTwoSecRedo(text: string, fn: () => void): Promise<void> {
doCopyTwoSecRedo(text: string, fn: () => void): void {
fn();
try {
await copyToClipboard(text);
setTimeout(fn, 2000);
} catch (error) {
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
this.notify.error("Failed to copy to clipboard.", TIMEOUTS.SHORT);
}
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
/**

View File

@@ -170,10 +170,10 @@ Raymer * @version 1.0.0 */
class="border-t p-2 border-slate-300"
@click="goToActivityToUserPage()"
>
<div class="flex justify-center">
<div class="flex justify-center gap-2">
<div
v-if="numNewOffersToUser"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
@@ -187,7 +187,7 @@ Raymer * @version 1.0.0 */
</div>
<div
v-if="numNewOffersToUserProjects"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
@@ -201,6 +201,22 @@ Raymer * @version 1.0.0 */
projects
</p>
</div>
<div
v-if="numNewStarredProjectChanges"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
data-testId="newStarredProjectChangesActivityNumber"
>
{{ numNewStarredProjectChanges
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
</span>
<p class="text-center">
favorite project{{ numNewStarredProjectChanges === 1 ? "" : "s" }}
with changes
</p>
</div>
</div>
<div class="flex justify-end mt-2">
<button class="text-blue-500">View All New Activity For You</button>
@@ -268,6 +284,7 @@ import {
getHeaders,
getNewOffersToUser,
getNewOffersToUserProjects,
getStarredProjectsWithChanges,
getPlanFromCache,
} from "../libs/endorserServer";
import {
@@ -283,6 +300,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications";
import * as Package from "../../package.json";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import * as databaseUtil from "../db/databaseUtil";
// consolidate this with GiveActionClaim in src/interfaces/claims.ts
interface Claim {
@@ -395,10 +413,14 @@ export default class HomeView extends Vue {
isRegistered = false;
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred project changes that they've acknowledged seeing
newOffersToUserHitLimit: boolean = false;
newOffersToUserProjectsHitLimit: boolean = false;
newStarredProjectChangesHitLimit: boolean = false;
numNewOffersToUser: number = 0; // number of new offers-to-user
numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects
numNewStarredProjectChanges: number = 0; // number of new starred project changes
starredPlanHandleIds: Array<string> = []; // list of starred project IDs
searchBoxes: Array<{
name: string;
bbox: BoundingBox;
@@ -438,6 +460,7 @@ export default class HomeView extends Vue {
// Registration check already handled in initializeIdentity()
await this.loadFeedData();
await this.loadNewOffers();
await this.loadNewStarredProjectChanges();
await this.checkOnboarding();
} catch (err: unknown) {
this.handleError(err);
@@ -542,8 +565,14 @@ export default class HomeView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId;
this.lastAckedStarredPlanChangesJwtId =
settings.lastAckedStarredPlanChangesJwtId;
this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc;
this.starredPlanHandleIds = databaseUtil.parseJsonField(
settings.starredPlanHandleIds,
[],
);
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
// Check onboarding status
@@ -675,6 +704,43 @@ export default class HomeView extends Vue {
}
}
/**
* Loads new changes for starred projects
* Updates:
* - Number of new starred project changes
* - Rate limit status for starred project changes
*
* @internal
* Called by mounted() and initializeIdentity()
* @requires Active DID
*/
private async loadNewStarredProjectChanges() {
if (this.activeDid && this.starredPlanHandleIds.length > 0) {
try {
const starredProjectChanges = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature
logger.warn(
"[HomeView] Failed to load starred project changes:",
error,
);
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
} else {
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
}
/**
* Checks if user needs onboarding using ultra-concise mixin utilities
* Opens onboarding dialog if not completed

View File

@@ -88,15 +88,9 @@ import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app";
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
import {
retrieveAccountCount,
importFromMnemonic,
checkForDuplicateAccount,
DUPLICATE_ACCOUNT_ERROR,
} from "../libs/util";
import { retrieveAccountCount, importFromMnemonic } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from "@/constants/notifications";
/**
* Import Account View Component
@@ -204,19 +198,6 @@ export default class ImportAccountView extends Vue {
}
try {
// Check for duplicate account before importing
const isDuplicate = await checkForDuplicateAccount(
this.mnemonic,
this.derivationPath,
);
if (isDuplicate) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
await importFromMnemonic(
this.mnemonic,
this.derivationPath,
@@ -242,20 +223,9 @@ export default class ImportAccountView extends Vue {
this.$router.push({ name: "account" });
} catch (error: unknown) {
this.$logError("Import failed: " + error);
// Check if this is a duplicate account error from saveNewIdentity
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes(DUPLICATE_ACCOUNT_ERROR)) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
this.notify.error(
errorMessage || "Failed to import account.",
(error instanceof Error ? error.message : String(error)) ||
"Failed to import account.",
TIMEOUTS.LONG,
);
}

View File

@@ -83,7 +83,6 @@ import {
retrieveAllAccountsMetadata,
retrieveFullyDecryptedAccount,
saveNewIdentity,
checkForDuplicateAccount,
} from "../libs/util";
import { logger } from "../utils/logger";
import { Account, AccountEncrypted } from "../db/tables/accounts";
@@ -172,16 +171,6 @@ export default class ImportAccountView extends Vue {
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
try {
// Check for duplicate account before creating
const isDuplicate = await checkForDuplicateAccount(newId.did);
if (isDuplicate) {
this.notify.warning(
"This derived account already exists. Please try a different derivation path.",
TIMEOUTS.LONG,
);
return;
}
await saveNewIdentity(newId, mne, newDerivPath);
// record that as the active DID

View File

@@ -128,7 +128,7 @@
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router";
import ContactNameDialog from "../components/ContactNameDialog.vue";
@@ -333,27 +333,17 @@ export default class InviteOneView extends Vue {
return `${APP_SERVER}/deep-link/invite-one-accept/${jwt}`;
}
async copyInviteAndNotify(inviteId: string, jwt: string) {
try {
await copyToClipboard(this.inviteLink(jwt));
this.notify.success(createInviteLinkCopyMessage(inviteId), TIMEOUTS.LONG);
} catch (error) {
this.$logAndConsole(`Error copying invite link: ${error}`, true);
this.notify.error("Failed to copy invite link.");
}
copyInviteAndNotify(inviteId: string, jwt: string) {
useClipboard().copy(this.inviteLink(jwt));
this.notify.success(createInviteLinkCopyMessage(inviteId), TIMEOUTS.LONG);
}
async showInvite(inviteId: string, redeemed: boolean, expired: boolean) {
try {
await copyToClipboard(inviteId);
this.notify.success(
createInviteIdCopyMessage(inviteId, redeemed, expired),
TIMEOUTS.LONG,
);
} catch (error) {
this.$logAndConsole(`Error copying invite ID: ${error}`, true);
this.notify.error("Failed to copy invite ID.");
}
showInvite(inviteId: string, redeemed: boolean, expired: boolean) {
useClipboard().copy(inviteId);
this.notify.success(
createInviteIdCopyMessage(inviteId, redeemed, expired),
TIMEOUTS.LONG,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

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

View File

@@ -110,22 +110,10 @@ export default class NewEditAccountView extends Vue {
* @async
*/
async onClickSaveChanges() {
// Get the current active DID to save to user-specific settings
const settings = await this.$accountSettings();
const activeDid = settings.activeDid;
if (activeDid) {
// Save to user-specific settings for the current identity
await this.$saveUserSettings(activeDid, {
firstName: this.givenName,
});
} else {
// Fallback to master settings if no active DID
await this.$saveSettings({
firstName: this.givenName,
});
}
await this.$updateSettings({
firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3
});
this.$router.back();
}

View File

@@ -270,7 +270,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
@@ -676,17 +676,12 @@ export default class OnboardMeetingView extends Vue {
this.notify.error(message, TIMEOUTS.LONG);
}
async copyMembersLinkToClipboard() {
try {
await copyToClipboard(this.onboardMeetingMembersLink());
this.notify.info(NOTIFY_MEETING_LINK_COPIED.message, TIMEOUTS.LONG);
} catch (error) {
this.$logAndConsole(
`Error copying meeting link to clipboard: ${error}`,
true,
);
this.notify.error("Failed to copy meeting link to clipboard.");
}
copyMembersLinkToClipboard() {
useClipboard()
.copy(this.onboardMeetingMembersLink())
.then(() => {
this.notify.info(NOTIFY_MEETING_LINK_COPIED.message, TIMEOUTS.LONG);
});
}
}
</script>

View File

@@ -27,10 +27,18 @@
>
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
<button title="Copy Link to Project" @click="onCopyLinkClick()">
<button
:title="
isStarred
? 'Remove from starred projects'
: 'Add to starred projects'
"
@click="toggleStar()"
>
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
:icon="isStarred ? 'star' : ['far', 'star']"
:class="isStarred ? 'text-yellow-500' : 'text-slate-500'"
class="text-sm ml-2 mb-1"
/>
</button>
</h2>
@@ -58,13 +66,13 @@
icon="user"
class="fa-fw text-slate-400"
></font-awesome>
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
<span class="truncate max-w-[calc(100%-2rem)] ml-1">
{{ issuerInfoObject?.displayName }}
</span>
<span
v-if="!serverUtil.isHiddenDid(issuer)"
class="inline-flex items-center"
class="inline-flex items-center ml-1"
>
<router-link
:to="{
@@ -140,18 +148,22 @@
<div class="text-sm text-slate-500">
<div v-if="!expanded">
{{ truncatedDesc }}
<vue-markdown
:source="truncatedDesc"
class="mb-4 markdown-content"
/>
<a
v-if="description.length >= truncateLength"
class="uppercase text-xs font-semibold text-slate-700"
class="mt-4 uppercase text-xs font-semibold text-blue-700 cursor-pointer"
@click="expandText"
>... Read More</a
>
</div>
<div v-else>
{{ description }}
<vue-markdown :source="description" class="mb-4 markdown-content" />
<a
class="uppercase text-xs font-semibold text-slate-700"
v-if="description.length >= truncateLength"
class="mt-4 uppercase text-xs font-semibold text-blue-700 cursor-pointer"
@click="collapseText"
>- Read Less</a
>
@@ -592,7 +604,10 @@
<script lang="ts">
import { AxiosError } from "axios";
import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import {
GenericVerifiableCredential,
GenericCredWrapper,
@@ -603,25 +618,24 @@ import {
PlanSummaryRecord,
} from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import OfferDialog from "../components/OfferDialog.vue";
import TopMessage from "../components/TopMessage.vue";
import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { NotificationIface } from "../constants/app";
// Removed legacy logging import - migrated to PlatformServiceMixin
import { APP_SERVER, NotificationIface } from "../constants/app";
import { UNNAMED_PROJECT } from "../constants/entities";
import { NOTIFY_CONFIRM_CLAIM } from "../constants/notifications";
import * as databaseUtil from "../db/databaseUtil";
import { Contact } from "../db/tables/contacts";
import * as libsUtil from "../libs/util";
import * as serverUtil from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger";
import { copyToClipboard } from "../services/ClipboardService";
import { logger } from "@/utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONFIRM_CLAIM } from "@/constants/notifications";
import { APP_SERVER } from "@/constants/app";
import { UNNAMED_PROJECT } from "@/constants/entities";
/**
* Project View Component
* @author Matthew Raymer
@@ -663,6 +677,7 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
ProjectIcon,
QuickNav,
TopMessage,
VueMarkdown,
},
mixins: [PlatformServiceMixin],
})
@@ -718,6 +733,8 @@ export default class ProjectViewView extends Vue {
givesProvidedByHitLimit = false;
givesTotalsByUnit: Array<{ unit: string; amount: number }> = [];
imageUrl = "";
/** Whether this project is starred by the user */
isStarred = false;
/** Project issuer DID */
issuer = "";
/** Cached issuer information */
@@ -756,7 +773,7 @@ export default class ProjectViewView extends Vue {
totalsExpanded = false;
truncatedDesc = "";
/** Truncation length */
truncateLength = 40;
truncateLength = 200;
// Utility References
libsUtil = libsUtil;
@@ -805,6 +822,12 @@ export default class ProjectViewView extends Vue {
}
this.loadProject(this.projectId, this.activeDid);
this.loadTotals();
// Check if this project is starred when settings are loaded
if (this.projectId && settings.starredPlanHandleIds) {
const starredIds = settings.starredPlanHandleIds || [];
this.isStarred = starredIds.includes(this.projectId);
}
}
/**
@@ -817,7 +840,7 @@ export default class ProjectViewView extends Vue {
});
}
async onCopyLinkClick() {
onCopyLinkClick() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
@@ -825,13 +848,11 @@ export default class ProjectViewView extends Vue {
: this.projectId;
// Use production URL for sharing to avoid localhost issues in development
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("link to this project", TIMEOUTS.SHORT);
} catch (error) {
this.$logAndConsole(`Error copying project link: ${error}`, true);
this.notify.error("Failed to copy project link.");
}
useClipboard()
.copy(deepLink)
.then(() => {
this.notify.copied("link to this project", TIMEOUTS.SHORT);
});
}
// Isn't there a better way to make this available to the template?
@@ -882,7 +903,7 @@ export default class ProjectViewView extends Vue {
);
this.issuerVisibleToDids = resp.data.issuerVisibleToDids || [];
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
this.description = resp.data.claim?.description || "";
this.truncatedDesc = this.description.slice(0, this.truncateLength);
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
@@ -1472,5 +1493,72 @@ export default class ProjectViewView extends Vue {
this.givesTotalsByUnit.find((total) => total.unit === "HUR")?.amount || 0
);
}
/**
* Toggle the starred status of the current project
*/
async toggleStar() {
if (!this.projectId) return;
try {
if (!this.isStarred) {
// Add to starred projects
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
const starredIds = settings.starredPlanHandleIds || [];
if (!starredIds.includes(this.projectId)) {
const newStarredIds = [...starredIds, this.projectId];
const newIdsParam = JSON.stringify(newStarredIds);
const result = await databaseUtil.updateDidSpecificSettings(
this.activeDid,
// @ts-expect-error until we use SettingsWithJsonString properly
{ starredPlanHandleIds: newIdsParam },
);
if (result) {
this.isStarred = true;
} else {
// eslint-disable-next-line no-console
console.log(
"Still getting a bad result from SQL update to star a project.",
);
}
}
if (!settings.lastAckedStarredPlanChangesJwtId) {
await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId: settings.lastViewedClaimId,
});
}
} else {
// Remove from starred projects
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
const starredIds = settings.starredPlanHandleIds || [];
const updatedIds = starredIds.filter((id) => id !== this.projectId);
const newIdsParam = JSON.stringify(updatedIds);
const result = await databaseUtil.updateDidSpecificSettings(
this.activeDid,
// @ts-expect-error until we use SettingsWithJsonString properly
{ starredPlanHandleIds: newIdsParam },
);
if (result) {
this.isStarred = false;
} else {
// eslint-disable-next-line no-console
logger.error("Got a bad result from SQL update to unstar a project.");
}
}
} catch (error) {
logger.error("Error toggling star status:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to update starred status. Please try again.",
},
3000,
);
}
}
}
</script>

View File

@@ -69,17 +69,10 @@
<div v-if="claimCountWithHidden > 0" class="border-b border-slate-300 pb-2">
<span>
{{ claimCountWithHiddenText }}
If you don't see expected info above for someone, ask them to check that
their activity is visible to you (
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
) on
<a
class="text-blue-500 underline cursor-pointer"
@click="copyContactsLinkToClipboard"
>
this page </a
>.
so if you expected but do not see details from someone then ask them to
check that their activity is visible to you on their Contacts
<font-awesome icon="users" class="text-slate-500" />
page.
</span>
</div>
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
@@ -127,11 +120,10 @@ import { DateTime } from "luxon";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { NotificationIface, APP_SERVER } from "../constants/app";
import { NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
GenericCredWrapper,
@@ -156,7 +148,6 @@ import {
NOTIFY_ALL_CONFIRMATIONS_ERROR,
NOTIFY_GIVE_SEND_ERROR,
NOTIFY_CLAIMS_SEND_ERROR,
NOTIFY_COPIED_TO_CLIPBOARD,
createConfirmationSuccessMessage,
createCombinedSuccessMessage,
} from "@/constants/notifications";
@@ -204,8 +195,8 @@ export default class QuickActionBvcEndView extends Vue {
get claimCountWithHiddenText() {
if (this.claimCountWithHidden === 0) return "";
return this.claimCountWithHidden === 1
? "There is 1 other claim with hidden details."
: `There are ${this.claimCountWithHidden} other claims with hidden details.`;
? "There is 1 other claim with hidden details,"
: `There are ${this.claimCountWithHidden} other claims with hidden details,`;
}
get claimCountByUserText() {
@@ -305,23 +296,6 @@ export default class QuickActionBvcEndView extends Vue {
(this.$router as Router).push(route);
}
async copyContactsLinkToClipboard() {
const deepLinkUrl = `${APP_SERVER}/deep-link/did/${this.activeDid}`;
try {
await copyToClipboard(deepLinkUrl);
this.notify.success(
NOTIFY_COPIED_TO_CLIPBOARD.message("Your info link"),
TIMEOUTS.SHORT,
);
} catch (error) {
logger.error("Failed to copy to clipboard:", error);
this.notify.error(
"Failed to copy link to clipboard. Please try again.",
TIMEOUTS.SHORT,
);
}
}
async record() {
try {
if (this.claimsToConfirmSelected.length > 0) {

View File

@@ -106,7 +106,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -231,24 +231,9 @@ export default class SeedBackupView extends Vue {
/**
* Reveals the seed phrase to the user
* Sets showSeed to true to display the sensitive seed phrase data
* Updates the hasBackedUpSeed setting to true to track that user has backed up
*/
async revealSeed(): Promise<void> {
revealSeed(): void {
this.showSeed = true;
// Update the account setting to track that user has backed up their seed
try {
const settings = await this.$accountSettings();
if (settings.activeDid) {
await this.$saveUserSettings(settings.activeDid, {
hasBackedUpSeed: true,
});
}
} catch (err: unknown) {
logger.error("Failed to update hasBackedUpSeed setting:", err);
// Don't show error to user as this is not critical to the main functionality
// The seed phrase is still revealed, just the tracking won't work
}
}
/**
@@ -279,15 +264,11 @@ export default class SeedBackupView extends Vue {
* @param text - The text to copy to clipboard
* @param fn - Callback function to execute for feedback (called twice - immediately and after 2 seconds)
*/
async doCopyTwoSecRedo(text: string, fn: () => void) {
doCopyTwoSecRedo(text: string, fn: () => void) {
fn();
try {
await copyToClipboard(text);
setTimeout(fn, 2000);
} catch (error) {
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
this.notify.error("Failed to copy to clipboard.");
}
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
}
</script>

View File

@@ -54,7 +54,6 @@ import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { Settings } from "@/db/tables/settings";
import { Account } from "@/db/tables/accounts";
import { copyToClipboard } from "../services/ClipboardService";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
// Constants for magic numbers
@@ -100,7 +99,7 @@ export default class ShareMyContactInfoView extends Vue {
}
const message = await this.generateContactMessage(settings, account);
await copyToClipboard(message);
await this.copyToClipboard(message);
await this.showSuccessNotifications();
this.navigateToContacts();
} catch (error) {
@@ -141,6 +140,14 @@ export default class ShareMyContactInfoView extends Vue {
);
}
/**
* Copy the contact message to clipboard
*/
private async copyToClipboard(message: string): Promise<void> {
const { copyToClipboard } = await import("../services/ClipboardService");
await copyToClipboard(message);
}
/**
* Show success notifications after copying
*/

View File

@@ -108,7 +108,7 @@ import { didInfo, getHeaders } from "../libs/endorserServer";
import { UserProfile } from "../libs/partnerServer";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_PROFILE_LOAD_ERROR } from "@/constants/notifications";
@@ -240,16 +240,14 @@ export default class UserProfileView extends Vue {
* Creates a deep link to the profile and copies it to the clipboard
* Shows success notification when completed
*/
async onCopyLinkClick() {
onCopyLinkClick() {
// Use production URL for sharing to avoid localhost issues in development
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("profile link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link.");
}
useClipboard()
.copy(deepLink)
.then(() => {
this.notify.copied("profile link", TIMEOUTS.STANDARD);
});
}
/**

View File

@@ -69,7 +69,8 @@
*/
import { test, expect } from '@playwright/test';
import { createContactName, generateNewEthrUser, importUser, importUserFromAccount } from './testUtils';
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
import { deleteContact, generateAndRegisterEthrUser, importUser } from './testUtils';
import { NOTIFY_CONTACT_INVALID_DID } from '../src/constants/notifications';
test('Check activity feed - check that server is running', async ({ page }) => {
@@ -184,20 +185,35 @@ test('Check invalid DID shows error and redirects', async ({ page }) => {
});
test('Check User 0 can register a random person', async ({ page }) => {
const newDid = await generateNewEthrUser(page); // generate a new user
await importUser(page, '00');
const newDid = await generateAndRegisterEthrUser(page);
expect(newDid).toContain('did:ethr:');
await importUserFromAccount(page, "00"); // switch to User Zero
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill('Gave me access!');
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// now ensure that alert goes away
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss alert
await expect(page.getByText('That gift was recorded.')).toBeHidden();
// As User Zero, add the new user as a contact
await page.goto('./contacts');
const contactName = createContactName(newDid);
await page.getByPlaceholder('URL or DID, Name, Public Key').fill(`${newDid}, ${contactName}`);
await expect(page.locator('button > svg.fa-plus')).toBeVisible();
await page.locator('button > svg.fa-plus').click();
await expect(page.locator('div[role="alert"] h4:has-text("Success")')).toBeVisible(); // wait for info alert to be visible…
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // …and dismiss it
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
await page.locator('div[role="alert"] button:text-is("Yes")').click(); // Register new contact
await page.locator('div[role="alert"] button:text-is("No, Not Now")').click(); // Dismiss export data prompt
await expect(page.locator("li", { hasText: contactName })).toBeVisible();
// now delete the contact to test that pages still do reasonable things
await deleteContact(page, newDid);
// go the activity page for this new person
await page.goto('./did/' + encodeURIComponent(newDid));
// maybe replace by: const popupPromise = page.waitForEvent('popup');
let error;
try {
await page.waitForSelector('div[role="alert"]', { timeout: 2000 });
error = new Error('Error alert should not show.');
} catch (error) {
// success
} finally {
if (error) {
throw error;
}
}
});

View File

@@ -1,63 +0,0 @@
import { test, expect } from '@playwright/test';
import { importUserFromAccount, getTestUserData } from './testUtils';
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from '../src/constants/notifications';
/**
* Test duplicate account import functionality
*
* This test verifies that:
* 1. A user can successfully import an account the first time
* 2. Attempting to import the same account again shows a warning message
* 3. The duplicate import is prevented
*/
test.describe('Duplicate Account Import', () => {
test('should prevent importing the same account twice', async ({ page }) => {
const userData = getTestUserData("00");
// First import - should succeed
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify first import was successful
await expect(page.getByRole("code")).toContainText(userData.did);
// Navigate back to start page for second import attempt
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify duplicate import shows warning message
// The warning can appear either from the pre-check or from the saveNewIdentity error handling
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).toBeVisible();
// Verify we're still on the import page (not redirected to account)
await expect(page.getByPlaceholder("Seed Phrase")).toBeVisible();
});
test('should allow importing different accounts', async ({ page }) => {
const userZeroData = getTestUserData("00");
const userOneData = getTestUserData("01");
// Import first user
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userZeroData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify first import was successful
await expect(page.getByRole("code")).toContainText(userZeroData.did);
// Navigate back to start page for second user import
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userOneData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify second import was successful (should not show duplicate warning)
await expect(page.getByRole("code")).toContainText(userOneData.did);
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).not.toBeVisible();
});
});

View File

@@ -8,7 +8,6 @@
* - Custom expiration date
* 2. The invitation appears in the list after creation
* 3. A new user can accept the invitation and become connected
* 4. The new user can create gift records from the front page
*
* Test Flow:
* 1. Imports User 0 (test account)
@@ -20,8 +19,6 @@
* 4. Creates a new user with Ethr DID
* 5. Accepts the invitation as the new user
* 6. Verifies the connection is established
* 7. Tests that the new user can create gift records from the front page
* 8. Verifies the gift appears in the home view
*
* Related Files:
* - Frontend invite handling: src/libs/endorserServer.ts
@@ -32,7 +29,7 @@
* @requires ./testUtils - For user management utilities
*/
import { test, expect } from '@playwright/test';
import { createGiftFromFrontPageForNewUser, deleteContact, generateNewEthrUser, generateRandomString, importUser, switchToUser } from './testUtils';
import { deleteContact, generateNewEthrUser, generateRandomString, importUser, switchToUser } from './testUtils';
test('Check User 0 can invite someone', async ({ page }) => {
await importUser(page, '00');
@@ -61,7 +58,4 @@ test('Check User 0 can invite someone', async ({ page }) => {
await page.locator('button:has-text("Save")').click();
await expect(page.locator('button:has-text("Save")')).toBeHidden();
await expect(page.locator(`li:has-text("My pal User #0")`)).toBeVisible();
// Verify the new user can create a gift record from the front page
const giftTitle = await createGiftFromFrontPageForNewUser(page, `Gift from new user ${neighborNum}`);
});

View File

@@ -1,5 +1,4 @@
import { expect, Page } from "@playwright/test";
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
// Get test user data based on the ID.
// '01' -> user 111
@@ -110,7 +109,7 @@ export async function switchToUser(page: Page, did: string): Promise<void> {
await page.getByTestId("didWrapper").locator('code:has-text("did:")');
}
export function createContactName(did: string): string {
function createContactName(did: string): string {
return "User " + did.slice(11, 14);
}
@@ -145,10 +144,35 @@ export async function generateNewEthrUser(page: Page): Promise<string> {
return newDid;
}
// Generate a new random user and register them.
// Note that this makes 000 the active user. Use switchToUser to switch to this DID.
export async function generateAndRegisterEthrUser(page: Page): Promise<string> {
const newDid = await generateNewEthrUser(page);
await importUser(page, "000"); // switch to user 000
await page.goto("./contacts");
const contactName = createContactName(newDid);
await page
.getByPlaceholder("URL or DID, Name, Public Key")
.fill(`${newDid}, ${contactName}`);
await page.locator("button > svg.fa-plus").click();
// register them
await page.locator('div[role="alert"] button:text-is("Yes")').click();
// wait for it to disappear because the next steps may depend on alerts being gone
await expect(
page.locator('div[role="alert"] button:text-is("Yes")')
).toBeHidden();
await expect(page.locator("li", { hasText: contactName })).toBeVisible();
return newDid;
}
// Function to generate a random string of specified length
// Note that this only generates up to 10 characters
export async function generateRandomString(length: number): Promise<string> {
return Math.random()
.toString(36)
.toString(36) // base 36 only generates up to 10 characters
.substring(2, 2 + length);
}
@@ -157,7 +181,7 @@ export async function createUniqueStringsArray(
count: number
): Promise<string[]> {
const stringsArray: string[] = [];
const stringLength = 16;
const stringLength = 5; // max of 10; see generateRandomString
for (let i = 0; i < count; i++) {
let randomString = await generateRandomString(stringLength);
@@ -216,44 +240,3 @@ export function isResourceIntensiveTest(testPath: string): boolean {
testPath.includes("40-add-contact")
);
}
/**
* Create a gift record from the front page
* @param page - Playwright page object
* @param giftTitle - Optional custom title, defaults to "Gift " + random string
* @param amount - Optional amount, defaults to random 1-99
* @returns Promise resolving to the created gift title
*/
export async function createGiftFromFrontPageForNewUser(
page: Page,
giftTitle?: string,
amount?: number
): Promise<void> {
// Generate random values if not provided
const randomString = Math.random().toString(36).substring(2, 6);
const finalTitle = giftTitle || `Gift ${randomString}`;
const finalAmount = amount || Math.floor(Math.random() * 99) + 1;
// Navigate to home page and close onboarding
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
// Start gift creation flow
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
// Fill gift details
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(finalAmount.toString());
// Submit gift
await page.getByRole('button', { name: 'Sign & Send' }).click();
// Verify success
await expect(page.getByText('That gift was recorded.')).toBeVisible();
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
// Verify the gift appears in the home view
await page.goto('./');
await expect(page.locator('ul#listLatestActivity li').filter({ hasText: giftTitle })).toBeVisible();
}