Compare commits

..
Author SHA1 Message Date
Matthew Raymer d8328ef89f Merge branch 'master' into android-file-save 2025-10-22 07:33:48 +00:00
Matthew Raymer a9b3f6dfab feat(export): implement unique filename generation with device identification
- Add generateUniqueFileName() method to all platform services
- Implement device ID hashing for privacy-friendly identification
- Add JSON validation before file operations
- Generate filenames with format: base_deviceHash_timestamp.json
- Support multiple exports in same second with counter system
- Ensure filenames fit within platform length limits (200 chars max)
- Update saveToDevice() and saveAs() methods across all platforms

Platforms updated:
- CapacitorPlatformService: Mobile file operations with unique names
- WebPlatformService: Browser download with device fingerprinting
- ElectronPlatformService: Desktop IPC with machine identification

Resolves file naming conflicts and improves debugging capabilities
while maintaining user privacy through hashed device identifiers.
2025-08-19 10:31:12 +00:00
Matthew Raymer 6b1937e37b feat(git-hooks): enhance pre-commit hook with whitelist support for console statements
Add whitelist functionality to debug checker to allow intentional console statements in specific files:
- Add WHITELIST_FILES configuration for platform services and utilities
- Update pre-commit hook to skip console pattern checks for whitelisted files
- Support regex patterns in whitelist for flexible file matching
- Maintain security while allowing legitimate debug code in platform services

This resolves the issue where the hook was blocking commits due to intentional console statements in whitelisted files like WebPlatformService and CapacitorPlatformService.
2025-08-19 07:41:45 +00:00
Matthew Raymer b735aac1fc feat(platform): implement dual-flow file sharing with Save to Device option
Add new platform service methods for direct file saving alongside existing share functionality:
- Add saveToDevice() and saveAs() methods to PlatformService interface
- Implement cross-platform support in WebPlatformService, ElectronPlatformService, and CapacitorPlatformService
- Update DataExportSection UI to provide Share Contacts and Save to Device buttons
- Add AndroidFileSaver plugin architecture with fallback implementation
- Include comprehensive documentation for native Android plugin implementation

This addresses the Android simulator file sharing limitation by providing users with clear choices between app-to-app sharing and direct device storage, while maintaining backward compatibility across all platforms.

- CapacitorPlatformService: Add MediaStore/SAF support with graceful fallbacks
- UI Components: Replace single download button with dual-action interface
- Documentation: Add AndroidFileSaver plugin implementation guide
- Type Safety: Maintain interface consistency across all platform services
2025-08-19 07:39:57 +00:00
27 changed files with 1485 additions and 803 deletions
+251
View File
@@ -0,0 +1,251 @@
# Android File Saver Plugin Implementation Guide
## Overview
This document outlines the implementation of the `AndroidFileSaver` Capacitor plugin that provides Storage Access Framework (SAF) and MediaStore functionality for direct file saving on Android devices.
## Plugin Purpose
The `AndroidFileSaver` plugin enables two key file operations:
1. **`saveToDownloads`**: Direct save to Downloads folder using MediaStore (API 29+)
2. **`saveAs`**: User-chosen location using Storage Access Framework (SAF)
## Implementation Requirements
### 1. Plugin Structure
```typescript
// Plugin interface
interface AndroidFileSaverPlugin {
saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string
}): Promise<{
success: boolean;
path?: string;
error?: string
}>;
saveAs(options: {
fileName: string;
content: string;
mimeType: string
}): Promise<{
success: boolean;
path?: string;
error?: string
}>;
}
```
### 2. Android Implementation
#### MediaStore for Downloads (API 29+)
```java
@PluginMethod
public void saveToDownloads(PluginCall call) {
String fileName = call.getString("fileName");
String content = call.getString("content");
String mimeType = call.getString("mimeType");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Use MediaStore for Downloads
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
values.put(MediaStore.Downloads.MIME_TYPE, mimeType);
values.put(MediaStore.Downloads.IS_PENDING, 1);
ContentResolver resolver = getContext().getContentResolver();
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
if (uri != null) {
try (ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "w")) {
if (pfd != null) {
try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) {
fos.write(content.getBytes());
fos.flush();
// Mark as no longer pending
values.clear();
values.put(MediaStore.Downloads.IS_PENDING, 0);
resolver.update(uri, values, null, null);
call.resolve(new JSObject()
.put("success", true)
.put("path", uri.toString()));
return;
}
}
} catch (IOException e) {
resolver.delete(uri, null, null);
}
}
call.resolve(new JSObject()
.put("success", false)
.put("error", "Failed to save file"));
} else {
// Fallback for older Android versions
call.resolve(new JSObject()
.put("success", false)
.put("error", "Requires Android API 29+"));
}
}
```
#### Storage Access Framework (SAF) for Save As
```java
@PluginMethod
public void saveAs(PluginCall call) {
String fileName = call.getString("fileName");
String content = call.getString("content");
String mimeType = call.getString("mimeType");
// Store call for later use
bridge.saveCall(call);
// Create intent for SAF
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, fileName);
// Start activity for result
bridge.startActivityForResult(call, intent, "saveAsResult");
}
@ActivityCallback
private void saveAsResult(PluginCall call, ActivityResult result) {
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
Uri uri = result.getData().getData();
String content = call.getString("content");
try (ParcelFileDescriptor pfd = getContext().getContentResolver()
.openFileDescriptor(uri, "w")) {
if (pfd != null) {
try (FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor())) {
fos.write(content.getBytes());
fos.flush();
call.resolve(new JSObject()
.put("success", true)
.put("path", uri.toString()));
return;
}
}
} catch (IOException e) {
// Handle error
}
call.resolve(new JSObject()
.put("success", false)
.put("error", "Failed to save file"));
} else {
call.resolve(new JSObject()
.put("success", false)
.put("error", "User cancelled or failed"));
}
}
```
### 3. Plugin Registration
```java
@CapacitorPlugin(name = "AndroidFileSaver")
public class AndroidFileSaverPlugin extends Plugin {
// Implementation methods here
}
```
## Integration Steps
### 1. Create Plugin Project
```bash
# Create new Capacitor plugin
npx @capacitor/cli plugin:generate android-file-saver
cd android-file-saver
```
### 2. Add to Android Project
```bash
# In your main project
npm install ./android-file-saver
npx cap sync android
```
### 3. Update Android Manifest
Ensure the following permissions are present:
```xml
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
```
## Fallback Behavior
When the plugin is not available, the system falls back to:
1. **Web**: Browser download mechanism
2. **Electron**: Native file save via IPC
3. **Capacitor**: Share dialog (existing behavior)
## Testing
### 1. Plugin Availability
```typescript
// Check if plugin is available
if (AndroidFileSaver) {
// Plugin available - use native methods
} else {
// Plugin not available - use fallback
}
```
### 2. Error Handling
```typescript
try {
const result = await AndroidFileSaver.saveToDownloads({
fileName: "test.json",
content: '{"test": "data"}',
mimeType: "application/json"
});
if (result.success) {
logger.info("File saved:", result.path);
} else {
logger.error("Save failed:", result.error);
}
} catch (error) {
logger.error("Plugin error:", error);
}
```
## Security Considerations
1. **Content Validation**: Validate file content before saving
2. **MIME Type Verification**: Ensure MIME type matches file content
3. **Permission Handling**: Request storage permissions appropriately
4. **Error Logging**: Log errors without exposing sensitive data
## Future Enhancements
1. **Progress Callbacks**: Add progress reporting for large files
2. **Batch Operations**: Support saving multiple files
3. **Custom Locations**: Allow saving to app-specific directories
4. **File Compression**: Add optional file compression
## References
- [Android Storage Access Framework](https://developer.android.com/guide/topics/providers/document-provider)
- [MediaStore Downloads](https://developer.android.com/reference/android/provider/MediaStore.Downloads)
- [Capacitor Plugin Development](https://capacitorjs.com/docs/plugins/android)
- [Android Scoped Storage](https://developer.android.com/training/data-storage)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.1-beta",
"version": "1.1.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.1-beta",
"version": "1.1.0-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
+1 -1
View File
@@ -38,7 +38,7 @@
}
.dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto;
@apply bg-white p-4 rounded-lg w-full max-w-lg;
}
/* Markdown content styling to restore list elements */
+11 -282
View File
@@ -77,86 +77,12 @@
</a>
</div>
<!-- Emoji Section -->
<div
v-if="hasEmojis || isRegistered"
class="float-right ml-3 mb-1 bg-white rounded border border-slate-300 px-1.5 py-0.5 max-w-[240px]"
>
<div class="flex items-center justify-between gap-1">
<!-- Existing Emojis Display -->
<div v-if="hasEmojis" class="flex flex-wrap gap-1">
<button
v-for="(count, emoji) in record.emojiCount"
:key="emoji"
class="inline-flex items-center gap-0.5 px-1 py-0.5 text-xs bg-slate-50 hover:bg-slate-100 rounded border border-slate-200 transition-colors cursor-pointer"
:class="{
'bg-blue-50 border-blue-200': isUserEmojiWithoutLoading(emoji),
'opacity-75 cursor-wait': loadingEmojis,
}"
:title="
loadingEmojis
? 'Loading...'
: !emojisOnActivity?.isResolved
? 'Click to load your emojis'
: isUserEmojiWithoutLoading(emoji)
? 'Click to remove your emoji'
: 'Click to add this emoji'
"
:disabled="!isRegistered"
@click="toggleThisEmoji(emoji)"
>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-xs">
<font-awesome icon="spinner" class="fa-spin" />
</div>
<span v-else class="text-sm leading-none">{{ emoji }}</span>
<span class="text-xs text-slate-600 font-medium leading-none">{{
count
}}</span>
</button>
</div>
<!-- Add Emoji Button -->
<button
v-if="isRegistered"
class="inline-flex px-1 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded border border-slate-300 transition-colors items-center justify-center ml-2 ml-auto"
:title="showEmojiPicker ? 'Close emoji picker' : 'Add emoji'"
@click="toggleEmojiPicker"
>
<span class="px-2 text-sm leading-none">{{
showEmojiPicker ? "x" : "😊"
}}</span>
</button>
</div>
<!-- Emoji Picker (placeholder for now) -->
<div
v-if="showEmojiPicker"
class="mt-1 p-1.5 bg-slate-50 rounded border border-slate-300"
>
<!-- Temporary emoji buttons for testing -->
<div class="flex flex-wrap gap-3 mt-1">
<button
v-for="emoji in QUICK_EMOJIS"
:key="emoji"
class="p-0.5 hover:bg-slate-200 rounded text-base transition-opacity"
:class="{
'opacity-75 cursor-wait': loadingEmojis,
}"
:disabled="loadingEmojis"
@click="toggleThisEmoji(emoji)"
>
<!-- Show spinner when loading -->
<div v-if="loadingEmojis" class="animate-spin text-sm">⟳</div>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div>
</div>
<!-- Description -->
<p class="font-medium">
<a class="block cursor-pointer" @click="emitLoadClaim(record.jwtId)">
<p class="font-medium overflow-hidden">
<a
class="block cursor-pointer overflow-hidden text-ellipsis"
@click="emitLoadClaim(record.jwtId)"
>
<vue-markdown
:source="truncatedDescription"
class="markdown-content"
@@ -165,7 +91,7 @@
</p>
<div
class="clear-right relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
>
<!-- Source -->
<div
@@ -328,24 +254,17 @@
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { logger } from "../utils/logger";
import {
createAndSubmitClaim,
getHeaders,
isHiddenDid,
} from "../libs/endorserServer";
import { GiveRecordWithContactInfo } from "@/interfaces/give";
import EntityIcon from "./EntityIcon.vue";
import { isHiddenDid } from "../libs/endorserServer";
import ProjectIcon from "./ProjectIcon.vue";
import { createNotifyHelpers, NotifyFunction, TIMEOUTS } from "@/utils/notify";
import { createNotifyHelpers, NotifyFunction } from "@/utils/notify";
import {
NOTIFY_PERSON_HIDDEN,
NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications";
import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces";
import { GiveRecordWithContactInfo } from "@/interfaces/give";
import { PromiseTracker } from "@/libs/util";
import { TIMEOUTS } from "@/utils/notify";
import VueMarkdown from "vue-markdown-render";
@Component({
components: {
@@ -355,24 +274,15 @@ import { PromiseTracker } from "@/libs/util";
},
})
export default class ActivityListItem extends Vue {
readonly QUICK_EMOJIS = ["👍", "👏", "❤️", "🎉", "😊", "😆", "🔥"];
@Prop() record!: GiveRecordWithContactInfo;
@Prop() lastViewedClaimId?: string;
@Prop() isRegistered!: boolean;
@Prop() activeDid!: string;
@Prop() apiServer!: string;
isHiddenDid = isHiddenDid;
notify!: ReturnType<typeof createNotifyHelpers>;
$notify!: NotifyFunction;
// Emoji-related data
showEmojiPicker = false;
loadingEmojis = false; // Track if emojis are currently loading
emojisOnActivity: PromiseTracker<EmojiSummaryRecord[]> | null = null; // load this only when needed
created() {
this.notify = createNotifyHelpers(this.$notify);
}
@@ -436,186 +346,5 @@ export default class ActivityListItem extends Vue {
day: "numeric",
});
}
// Emoji-related computed properties and methods
get hasEmojis(): boolean {
return Object.keys(this.record.emojiCount).length > 0;
}
triggerUserEmojiLoad(): PromiseTracker<EmojiSummaryRecord[]> {
if (!this.emojisOnActivity) {
const promise = new Promise<EmojiSummaryRecord[]>((resolve) => {
(async () => {
this.axios
.get(
`${this.apiServer}/api/v2/report/emoji?parentHandleId=${encodeURIComponent(this.record.handleId)}`,
{ headers: await getHeaders(this.activeDid) },
)
.then((response) => {
const userEmojiRecords = response.data.data.filter(
(e: EmojiSummaryRecord) => e.issuerDid === this.activeDid,
);
resolve(userEmojiRecords);
})
.catch((error) => {
logger.error("Error loading user emojis:", error);
resolve([]);
});
})();
});
this.emojisOnActivity = new PromiseTracker(promise);
}
return this.emojisOnActivity;
}
/**
*
* @param emoji - The emoji to check.
* @returns True if the emoji is in the user's emojis, false otherwise.
*
* @note This method is quick and synchronous, and can check resolved emojis
* without triggering a server request. Returns false if emojis haven't been loaded yet.
*/
isUserEmojiWithoutLoading(emoji: string): boolean {
if (this.emojisOnActivity?.isResolved && this.emojisOnActivity.value) {
return this.emojisOnActivity.value.some(
(record) => record.text === emoji,
);
}
return false;
}
async toggleEmojiPicker() {
this.triggerUserEmojiLoad(); // trigger it, but don't wait for it to complete
this.showEmojiPicker = !this.showEmojiPicker;
}
async toggleThisEmoji(emoji: string) {
// Start loading indicator
this.loadingEmojis = true;
this.showEmojiPicker = false; // always close the picker when an emoji is clicked
try {
this.triggerUserEmojiLoad(); // trigger just in case
const userEmojiList = await this.emojisOnActivity!.promise; // must wait now that they've chosen
const userHasEmoji: boolean = userEmojiList.some(
(record) => record.text === emoji,
);
if (userHasEmoji) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Remove Emoji",
text: `Do you want to remove your ${emoji} ?`,
yesText: "Remove",
onYes: async () => {
await this.removeEmoji(emoji);
},
},
TIMEOUTS.MODAL,
);
} else {
// User doesn't have this emoji, add it
await this.submitEmoji(emoji);
}
} finally {
// Remove loading indicator
this.loadingEmojis = false;
}
}
async submitEmoji(emoji: string) {
try {
// Create an Emoji claim and send to the server
const emojiClaim: GenericVerifiableCredential = {
"@context": "https://endorser.ch",
"@type": "Emoji",
text: emoji,
parentItem: { lastClaimId: this.record.jwtId },
};
const claim = await createAndSubmitClaim(
emojiClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (claim.success && !claim.embeddedRecordError) {
// Update emoji count
this.record.emojiCount[emoji] =
(this.record.emojiCount[emoji] || 0) + 1;
// Create a new emoji record (we'll get the actual jwtId from the server response later)
const newEmojiRecord: EmojiSummaryRecord = {
issuerDid: this.activeDid,
jwtId: claim.claimId || "",
text: emoji,
parentHandleId: this.record.jwtId,
};
// Update user emojis list by creating a new promise with the updated data
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
this.triggerUserEmojiLoad();
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
this.emojisOnActivity = new PromiseTracker(
Promise.resolve([...currentEmojis, newEmojiRecord]),
);
} else {
this.notify.error("Failed to add emoji.", TIMEOUTS.STANDARD);
}
} catch (error) {
logger.error("Error submitting emoji:", error);
this.notify.error("Got error adding emoji.", TIMEOUTS.STANDARD);
}
}
async removeEmoji(emoji: string) {
try {
// Create an Emoji claim and send to the server
const emojiClaim: GenericVerifiableCredential = {
"@context": "https://endorser.ch",
"@type": "Emoji",
text: emoji,
parentItem: { lastClaimId: this.record.jwtId },
};
const claim = await createAndSubmitClaim(
emojiClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (claim.success && !claim.embeddedRecordError) {
// Update emoji count
const newCount = Math.max(0, (this.record.emojiCount[emoji] || 0) - 1);
if (newCount === 0) {
delete this.record.emojiCount[emoji];
} else {
this.record.emojiCount[emoji] = newCount;
}
// Update user emojis list by creating a new promise with the updated data
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
this.triggerUserEmojiLoad();
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
this.emojisOnActivity = new PromiseTracker(
Promise.resolve(
currentEmojis.filter(
(record) =>
record.issuerDid === this.activeDid && record.text !== emoji,
),
),
);
} else {
this.notify.error("Failed to remove emoji.", TIMEOUTS.STANDARD);
}
} catch (error) {
logger.error("Error removing emoji:", error);
this.notify.error("Got error removing emoji.", TIMEOUTS.STANDARD);
}
}
}
</script>
+136 -15
View File
@@ -25,33 +25,46 @@ messages * - Conditional UI based on platform capabilities * * @component *
Backup Identifier Seed
</router-link>
<button
:disabled="isExporting"
:class="exportButtonClasses"
@click="exportDatabase()"
>
{{ isExporting ? "Exporting..." : "Download Contacts" }}
</button>
<div class="flex flex-col gap-2 mt-2">
<button
:disabled="isExporting"
:class="shareButtonClasses"
@click="shareContacts()"
>
{{ isExporting ? "Exporting..." : "Share Contacts" }}
</button>
<button
:disabled="isExporting"
:class="saveButtonClasses"
@click="saveContactsToDevice()"
>
{{ isExporting ? "Exporting..." : "Save to Device" }}
</button>
</div>
<div
v-if="capabilities.needsFileHandlingInstructions"
:class="instructionsContainerClasses"
>
<p>
After the export, you can save the file in your preferred storage
location.
</p>
<p>Choose how you want to export your contacts:</p>
<ul>
<li :class="listItemClasses">
<strong>Share Contacts:</strong> Opens the system share dialog to send
to apps like Gmail, Drive, or messaging apps.
</li>
<li :class="listItemClasses">
<strong>Save to Device:</strong> Saves directly to your device's
Downloads folder (Android) or Documents folder (iOS).
</li>
<li v-if="capabilities.isIOS" :class="listItemClasses">
On iOS: You will be prompted to choose a location to save your backup
file.
On iOS: Files are saved to the Files app in your Documents folder.
</li>
<li
v-if="capabilities.isMobile && !capabilities.isIOS"
:class="listItemClasses"
>
On Android: You will be prompted to choose a location to save your
backup file.
On Android: Files are saved directly to your Downloads folder.
</li>
</ul>
</div>
@@ -151,6 +164,20 @@ export default class DataExportSection extends Vue {
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 disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the share button
*/
get shareButtonClasses(): string {
return "block w-full text-center text-md bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the save to device button
*/
get saveButtonClasses(): string {
return "block w-full text-center text-md bg-gradient-to-b from-purple-400 to-purple-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed";
}
/**
* CSS classes for the instructions container
*/
@@ -228,6 +255,100 @@ export default class DataExportSection extends Vue {
}
}
/**
* Shares contacts data using the platform's share functionality
* Opens the system share dialog for app-to-app handoff
*
* @throws {Error} If sharing fails
*/
public async shareContacts(): Promise<void> {
if (this.isExporting) {
return; // Prevent multiple simultaneous exports
}
try {
this.isExporting = true;
// Fetch contacts from database using mixin's cached method
const allContacts = await this.$contacts();
// Convert contacts to export format
const processedContacts: Contact[] = allContacts.map((contact) => {
const exContact: Contact = R.omit(["contactMethods"], contact);
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
return exContact;
});
const exportData = contactsToExportJson(processedContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
// Use platform service to share the file
await this.platformService.writeAndShareFile(this.fileName, jsonStr);
this.notify.success(
"Contact sharing completed successfully. Use the share dialog to send to your preferred app.",
);
} catch (error) {
logger.error("Share Error:", error);
this.notify.error(
`There was an error sharing the data: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
this.isExporting = false;
}
}
/**
* Saves contacts data directly to the device's storage
* Uses platform-specific save methods (Downloads folder, Documents, etc.)
*
* @throws {Error} If saving fails
*/
public async saveContactsToDevice(): Promise<void> {
if (this.isExporting) {
return; // Prevent multiple simultaneous exports
}
try {
this.isExporting = true;
// Fetch contacts from database using mixin's cached method
const allContacts = await this.$contacts();
// Convert contacts to export format
const processedContacts: Contact[] = allContacts.map((contact) => {
const exContact: Contact = R.omit(["contactMethods"], contact);
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
return exContact;
});
const exportData = contactsToExportJson(processedContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
// Use platform service to save directly to device
await this.platformService.saveToDevice(this.fileName, jsonStr);
this.notify.success("Contact data saved successfully to your device.");
} catch (error) {
logger.error("Save Error:", error);
this.notify.error(
`There was an error saving the data: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
this.isExporting = false;
}
}
created() {
this.notify = createNotifyHelpers(this.$notify);
this.loadSeedBackupStatus();
+84 -344
View File
@@ -2,55 +2,12 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */
<template>
<!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
<input
v-model="searchTerm"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
@input="handleSearchInput"
@keydown.enter="performSearch"
/>
<div
v-show="isSearching && searchTerm"
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
>
<font-awesome
icon="spinner"
class="fa-spin-pulse leading-[1.1]"
></font-awesome>
</div>
<button
:disabled="!searchTerm"
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
@click="clearSearch"
>
<font-awesome
:icon="searchTerm ? 'times' : 'magnifying-glass'"
class="fa-fw"
></font-awesome>
</button>
</div>
<div
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
class="mb-4 text-sm italic text-slate-500 text-center"
>
“{{ searchTerm }}” doesn't match any
{{ entityType === "people" ? "people" : "projects" }}. Try a different
search.
</div>
<ul
ref="scrollContainer"
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
>
<ul :class="gridClasses">
<!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'">
<!-- "You" entity -->
<SpecialEntityCard
v-if="showYouEntity && !searchTerm.trim()"
v-if="showYouEntity"
entity-type="you"
label="You"
icon="hand"
@@ -64,7 +21,6 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity -->
<SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed"
:label="unnamedEntityName"
icon="circle-question"
@@ -82,60 +38,16 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'">
<!-- When showing contacts without search: split into recent and alphabetical -->
<template v-if="!searchTerm.trim()">
<!-- Recently Added Section -->
<template v-if="recentContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Added
</li>
<PersonCard
v-for="person in recentContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<!-- Alphabetical Section -->
<template v-if="alphabeticalContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone Else
</li>
<PersonCard
v-for="person in alphabeticalContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<template v-else-if="entityType === 'projects'">
@@ -151,27 +63,28 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected"
/>
</template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* Constants for infinite scroll configuration
*/
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
/**
* EntityGrid - Unified grid layout for displaying people or projects
*
@@ -180,6 +93,7 @@ const RECENT_CONTACTS_COUNT = 3;
* - Special entity integration (You, Unnamed)
* - Conflict detection integration
* - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties
@@ -190,6 +104,7 @@ const RECENT_CONTACTS_COUNT = 3;
PersonCard,
ProjectCard,
SpecialEntityCard,
ShowAllCard,
},
})
export default class EntityGrid extends Vue {
@@ -197,21 +112,14 @@ export default class EntityGrid extends Vue {
@Prop({ required: true })
entityType!: "people" | "projects";
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/** Array of entities to display */
@Prop({ required: true })
entities!: Contact[] | PlanData[];
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
@@ -232,14 +140,18 @@ export default class EntityGrid extends Vue {
@Prop({ default: true })
showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */
@Prop({ default: true })
youSelectable!: boolean;
/** Route name for "Show All" navigation */
@Prop({ default: "" })
showAllRoute!: string;
/** Query parameters for "Show All" navigation */
@Prop({ default: () => ({}) })
showAllQueryParams!: Record<string, string>;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
@@ -248,31 +160,42 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* Function to determine which entities to display (allows parent control)
*
* This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering and sorting.
* Note: Infinite scroll is disabled when this prop is provided.
* are displayed in the grid, enabling advanced filtering, sorting, and
* display logic beyond the default simple slice behavior.
*
* @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects")
* @param maxItems - The maximum number of items to display (from maxItems prop)
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
* :display-entities-function="(entities, type, max) =>
* entities.filter(e => e.profileImageUrl).slice(0, max)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
* :display-entities-function="(entities, type, max) =>
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
*
* @example
* // Advanced logic: different limits for different entity types
* :display-entities-function="(entities, type, max) =>
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[];
/**
@@ -283,60 +206,33 @@ export default class EntityGrid extends Vue {
}
/**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
* Computed CSS classes for the grid layout
*/
get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise defaults
*/
get displayedEntities(): Contact[] | PlanData[] {
// If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType);
return this.displayEntitiesFunction(
this.entities,
this.entityType,
this.maxItems,
);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entities as PlanData[]).slice(0, this.displayedCount);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Get the 3 most recently added contacts (when showing contacts and not searching)
*/
get recentContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, 3);
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Skip the first 3 (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
// Default implementation for backward compatibility
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return this.entities.slice(0, maxDisplay);
}
/**
@@ -350,6 +246,15 @@ export default class EntityGrid extends Vue {
}
}
/**
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return (
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
);
}
/**
* Whether the "You" entity is conflicted
*/
@@ -423,144 +328,6 @@ export default class EntityGrid extends Vue {
});
}
/**
* Handle search input with debouncing
*/
handleSearchInput(): void {
// Show spinner immediately when user types
this.isSearching = true;
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for 500ms delay
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 500);
}
/**
* Perform the actual search
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
try {
// Simulate async search (in case we need to add API calls later)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
if (this.entityType === "people") {
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
} else {
this.filteredEntities = (this.entities as PlanData[])
.filter((project: PlanData) => {
const name = project.name?.toLowerCase() || "";
const handleId = project.handleId.toLowerCase();
return name.includes(searchLower) || handleId.includes(searchLower);
})
.sort((a: PlanData, b: PlanData) => {
// Sort alphabetically by name
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// Clear any pending timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
/**
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// Search mode: check filtered entities
return this.displayedCount < this.filteredEntities.length;
}
if (this.entityType === "projects") {
// Projects: check if more available
return this.displayedCount < this.entities.length;
}
// People: check if more alphabetical contacts available
// Total available = 3 recent + all alphabetical
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
mounted(): void {
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
() => {
// Load more: increment displayedCount
this.displayedCount += INCREMENT_SIZE;
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
@@ -573,33 +340,6 @@ export default class EntityGrid extends Vue {
} {
return data;
}
/**
* Watch for changes in search term to reset displayed count
*/
@Watch("searchTerm")
onSearchTermChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
}
</script>
+66 -3
View File
@@ -3,9 +3,10 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Cancel functionality * - Event delegation for entity selection * - Warning
notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
Show All navigation with context preservation * - Cancel functionality * - Event
delegation for entity selection * - Warning notifications for conflicted
entities * - Template streamlined with computed CSS properties * * @author
Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
@@ -15,14 +16,18 @@ properties * * @author Matthew Raymer */
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify"
:conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
/>
@@ -63,6 +68,7 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display
* - Conflict detection and prevention
* - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
@@ -148,6 +154,10 @@ export default class EntitySelectionStep extends Vue {
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* CSS classes for the cancel button
*/
@@ -212,6 +222,59 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Route name for "Show All" navigation
*/
get showAllRoute(): string {
if (this.shouldShowProjects) {
return "discover";
} else if (this.allContacts.length > 0) {
return "contact-gift";
}
return "";
}
/**
* Query parameters for "Show All" navigation
*/
get showAllQueryParams(): Record<string, string> {
const baseParams = {
stepType: this.stepType,
giverEntityType: this.giverEntityType,
recipientEntityType: this.recipientEntityType,
// Form field values to preserve
description: this.description,
amountInput: this.amountInput,
unitCode: this.unitCode,
offerId: this.offerId,
fromProjectId: this.fromProjectId,
toProjectId: this.toProjectId,
showProjects: this.showProjects.toString(),
isFromProjectView: this.isFromProjectView.toString(),
};
if (this.shouldShowProjects) {
// For project contexts, still pass entity type information
return baseParams;
}
return {
...baseParams,
// Always pass both giver and recipient info for context preservation
giverProjectId: this.fromProjectId || "",
giverProjectName: this.giver?.name || "",
giverProjectImage: this.giver?.image || "",
giverProjectHandleId: this.giver?.handleId || "",
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
recipientProjectId: this.toProjectId || "",
recipientProjectName: this.receiver?.name || "",
recipientProjectImage: this.receiver?.image || "",
recipientProjectHandleId: this.receiver?.handleId || "",
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
};
}
/**
* Handle entity selection from EntityGrid
*/
+3 -1
View File
@@ -29,6 +29,7 @@
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
@cancel="cancel"
/>
@@ -116,6 +117,7 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as
| "person"
| "project";
@@ -231,7 +233,7 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer,
});
this.allContacts = await this.$contactsByDateAdded();
this.allContacts = await this.$contacts();
this.allMyDids = await retrieveAccountDids();
+21 -19
View File
@@ -3,25 +3,30 @@ GiftedDialog.vue to handle person entity display * with selection states and
conflict detection. * * @author Matthew Raymer */
<template>
<li :class="cardClasses" @click="handleClick">
<div>
<div class="relative w-fit mx-auto">
<EntityIcon
v-if="person.did"
:contact="person"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-5xl mb-1 shrink-0"
class="text-slate-400 text-5xl mb-1"
/>
<!-- Time icon overlay for contacts -->
<div
v-if="person.did && showTimeIcon"
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
</div>
</div>
<div class="overflow-hidden">
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
</div>
<h3 :class="nameClasses">
{{ displayName }}
</h3>
</li>
</template>
@@ -76,32 +81,29 @@ export default class PersonCard extends Vue {
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
return "opacity-50 cursor-not-allowed";
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
return "cursor-pointer hover:bg-slate-50";
}
/**
* Computed CSS classes for the person name
*/
get nameClasses(): string {
const baseNameClasses = "text-sm font-semibold truncate";
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseNameClasses} text-slate-500`;
return `${baseClasses} text-slate-400`;
}
// Add italic styling for entities without set names
if (!this.person.name) {
return `${baseNameClasses} italic text-slate-500`;
return `${baseClasses} italic text-slate-500`;
}
return baseNameClasses;
return baseClasses;
}
/**
+17 -18
View File
@@ -2,26 +2,25 @@
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
<template>
<li
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<li class="cursor-pointer" @click="handleClick">
<div class="relative w-fit mx-auto">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
</div>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
</h3>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ project.name || unnamedProject }}
</h3>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="text-slate-400" />
{{ issuerDisplayName }}
</div>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ issuerDisplayName }}
</div>
</li>
</template>
+66
View File
@@ -0,0 +1,66 @@
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
entity types. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer">
<router-link :to="navigationRoute" class="block text-center">
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
<h3
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { RouteLocationRaw } from "vue-router";
/**
* ShowAllCard - Displays "Show All" navigation for entity grids
*
* Features:
* - Provides navigation to full entity listings
* - Supports different routes based on entity type
* - Maintains context through query parameters
* - Consistent visual styling with other cards
*/
@Component({ name: "ShowAllCard" })
export default class ShowAllCard extends Vue {
/** Type of entities being shown */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Route name to navigate to */
@Prop({ required: true })
routeName!: string;
/** Query parameters to pass to the route */
@Prop({ default: () => ({}) })
queryParams!: Record<string, string>;
/**
* Computed navigation route with query parameters
*/
get navigationRoute(): RouteLocationRaw {
return {
name: this.routeName,
query: this.queryParams,
};
}
}
</script>
<style scoped>
/* Ensure router-link styling is consistent */
a {
text-decoration: none;
}
a:hover .fa-circle-right {
transform: scale(1.1);
transition: transform 0.2s ease;
}
</style>
+6 -7
View File
@@ -63,24 +63,23 @@ export default class SpecialEntityCard extends Vue {
conflictContext!: string;
/**
* Computed CSS classes for the card
* Computed CSS classes for the card container
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
const baseClasses = "block";
if (!this.selectable || this.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
return `${baseClasses} cursor-not-allowed opacity-50`;
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
return `${baseClasses} cursor-pointer`;
}
/**
* Computed CSS classes for the icon
*/
get iconClasses(): string {
const baseClasses = "text-[2rem]";
const baseClasses = "text-5xl mb-1";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
@@ -102,7 +101,7 @@ export default class SpecialEntityCard extends Vue {
*/
get nameClasses(): string {
const baseClasses =
"text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
+25 -9
View File
@@ -234,20 +234,32 @@ export async function runMigrations<T>(
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
logger.debug("[Migration] Starting database migrations");
// Only log migration start in development
const isDevelopment = process.env.VITE_PLATFORM === "development";
if (isDevelopment) {
logger.debug("[Migration] Starting database migrations");
}
for (const migration of MIGRATIONS) {
logger.debug("[Migration] Registering migration:", migration.name);
if (isDevelopment) {
logger.debug("[Migration] Registering migration:", migration.name);
}
registerMigration(migration);
}
logger.debug("[Migration] Running migration service");
if (isDevelopment) {
logger.debug("[Migration] Running migration service");
}
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
logger.debug("[Migration] Database migrations completed");
if (isDevelopment) {
logger.debug("[Migration] Database migrations completed");
}
// Bootstrapping: Ensure active account is selected after migrations
logger.debug("[Migration] Running bootstrapping hooks");
if (isDevelopment) {
logger.debug("[Migration] Running bootstrapping hooks");
}
try {
// Check if we have accounts but no active selection
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
@@ -262,14 +274,18 @@ export async function runMigrations<T>(
activeDid = (extractSingleValue(activeResult) as string) || null;
} catch (error) {
// Table doesn't exist - migration 004 may not have run yet
logger.debug(
"[Migration] active_identity table not found - migration may not have run",
);
if (isDevelopment) {
logger.debug(
"[Migration] active_identity table not found - migration may not have run",
);
}
activeDid = null;
}
if (accountsCount > 0 && (!activeDid || activeDid === "")) {
logger.debug("[Migration] Auto-selecting first account as active");
if (isDevelopment) {
logger.debug("[Migration] Auto-selecting first account as active");
}
const firstAccountResult = await sqlQuery(
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
);
-7
View File
@@ -14,13 +14,6 @@ export interface AgreeActionClaim extends ClaimObject {
object: Record<string, unknown>;
}
export interface EmojiClaim extends ClaimObject {
// default context is "https://endorser.ch"
"@type": "Emoji";
text: string;
parentItem: { lastClaimId: string };
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveActionClaim extends ClaimObject {
-2
View File
@@ -81,9 +81,7 @@ export interface UserInfo {
export interface CreateAndSubmitClaimResult {
success: boolean;
embeddedRecordError?: string;
error?: string;
claimId?: string;
handleId?: string;
}
+34 -4
View File
@@ -1,6 +1,36 @@
export * from "./claims";
export * from "./claims-result";
export * from "./common";
export * from "./deepLinks";
export type {
// From common.ts
CreateAndSubmitClaimResult,
GenericCredWrapper,
GenericVerifiableCredential,
KeyMeta,
// Exclude types that are also exported from other files
// GiveVerifiableCredential,
// OfferVerifiableCredential,
// RegisterVerifiableCredential,
// PlanSummaryRecord,
// UserInfo,
} from "./common";
export type {
// From claims.ts
GiveActionClaim,
OfferClaim,
RegisterActionClaim,
} from "./claims";
export type {
// From records.ts
PlanSummaryRecord,
} from "./records";
export type {
// From user.ts
UserInfo,
} from "./user";
export * from "./limits";
export * from "./deepLinks";
export * from "./common";
export * from "./claims-result";
export * from "./records";
+1 -13
View File
@@ -1,26 +1,14 @@
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
import { GenericCredWrapper } from "./common";
export interface EmojiSummaryRecord {
issuerDid: string;
jwtId: string;
text: string;
parentHandleId: string;
}
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
[x: string]:
| PropertyKey
| undefined
| GiveActionClaim
| Record<string, number>;
[x: string]: PropertyKey | undefined | GiveActionClaim;
type?: string;
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
emojiCount: Record<string, number>; // Map of emoji character to count
fullClaim: GiveActionClaim;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;
+6 -7
View File
@@ -630,7 +630,11 @@ async function performPlanRequest(
return cred;
} else {
logger.debug(
// Use debug level for development to reduce console noise
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log(
"[Plan Loading] ⚠️ Plan cache is empty for handle",
handleId,
" Got data:",
@@ -1222,12 +1226,7 @@ export async function createAndSubmitClaim(
timestamp: new Date().toISOString(),
});
return {
success: true,
claimId: response.data?.claimId,
handleId: response.data?.handleId,
embeddedRecordError: response.data?.embeddedRecordError,
};
return { success: true, handleId: response.data?.handleId };
} catch (error: unknown) {
// Enhanced error logging with comprehensive context
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
-26
View File
@@ -1147,29 +1147,3 @@ export async function checkForDuplicateAccount(
return (existingAccount?.values?.length ?? 0) > 0;
}
export class PromiseTracker<T> {
private _promise: Promise<T>;
private _resolved = false;
private _value: T | undefined;
constructor(promise: Promise<T>) {
this._promise = promise.then((value) => {
this._resolved = true;
this._value = value;
return value;
});
}
get isResolved(): boolean {
return this._resolved;
}
get value(): T | undefined {
return this._value;
}
get promise(): Promise<T> {
return this._promise;
}
}
+18
View File
@@ -88,6 +88,24 @@ export interface PlatformService {
*/
writeAndShareFile(fileName: string, content: string): Promise<void>;
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
saveToDevice(fileName: string, content: string): Promise<void>;
/**
* Opens the system file picker to let the user choose where to save a file.
* Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms.
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
saveAs(fileName: string, content: string): Promise<void>;
/**
* Deletes a file at the specified path.
* @param path - The path to the file to delete
@@ -14,6 +14,65 @@ import {
DBSQLiteValues,
} from "@capacitor-community/sqlite";
// Android-specific imports for SAF and MediaStore
import { registerPlugin } from "@capacitor/core";
// Define interfaces for Android-specific functionality
interface AndroidFileSaverPlugin {
saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }>;
saveAs(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }>;
}
// Register the plugin (will be undefined if not available)
const AndroidFileSaver =
registerPlugin<AndroidFileSaverPlugin>("AndroidFileSaver");
// Fallback implementation for when the plugin is not available
class AndroidFileSaverFallback implements AndroidFileSaverPlugin {
async saveToDownloads(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }> {
logger.warn(
"[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback",
{
fileName: options.fileName,
mimeType: options.mimeType,
contentLength: options.content.length,
},
);
return { success: false, error: "AndroidFileSaver plugin not available" };
}
async saveAs(options: {
fileName: string;
content: string;
mimeType: string;
}): Promise<{ success: boolean; path?: string; error?: string }> {
logger.warn(
"[CapacitorPlatformService] AndroidFileSaver plugin not available, using fallback",
{
fileName: options.fileName,
mimeType: options.mimeType,
contentLength: options.content.length,
},
);
return { success: false, error: "AndroidFileSaver plugin not available" };
}
}
// Use fallback if plugin is not available
const AndroidFileSaverImpl = AndroidFileSaver || new AndroidFileSaverFallback();
import { runMigrations } from "@/db-sql/migration";
import { QueryExecResult } from "@/interfaces/database";
import {
@@ -468,7 +527,7 @@ export class CapacitorPlatformService implements PlatformService {
* ## Logging:
*
* Detailed logging is provided throughout the process using emoji-tagged
* console messages that appear in the Electron DevTools console. This
* log messages that appear in the Electron DevTools. This
* includes:
* - SQL statement execution details
* - Parameter values for debugging
@@ -1119,6 +1178,280 @@ export class CapacitorPlatformService implements PlatformService {
}
}
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: "saveToDevice",
fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp,
};
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
// Validate JSON content
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
if (this.getCapabilities().isIOS) {
// iOS: Use Filesystem to save to Documents directory
const { uri } = await Filesystem.writeFile({
path: uniqueFileName,
data: content,
directory: Directory.Documents,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
uri,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
});
} else {
// Android: Try to use native MediaStore/SAF implementation
const result = await AndroidFileSaverImpl.saveToDownloads({
fileName: uniqueFileName,
content,
mimeType: this.getMimeType(uniqueFileName),
});
if (result.success) {
logger.log(
"[CapacitorPlatformService] File saved to Android Downloads:",
{
path: result.path,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
},
);
} else {
throw new Error(`Failed to save to Downloads: ${result.error}`);
}
}
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error(
"[CapacitorPlatformService] Error saving file to device:",
JSON.stringify(errLog, null, 2),
);
throw new Error(`Failed to save file to device: ${err.message}`);
}
}
/**
* Opens the system file picker to let the user choose where to save a file.
* Uses Storage Access Framework (SAF) on Android and appropriate APIs on other platforms.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: "saveAs",
fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp,
};
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
// Validate JSON content
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
if (this.getCapabilities().isIOS) {
// iOS: Use Filesystem to save to Documents directory with user choice
const { uri } = await Filesystem.writeFile({
path: uniqueFileName,
data: content,
directory: Directory.Documents,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File saved to iOS Documents:", {
uri,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
});
} else {
// Android: Use SAF for user-chosen location
const result = await AndroidFileSaverImpl.saveAs({
fileName: uniqueFileName,
content,
mimeType: this.getMimeType(uniqueFileName),
});
if (result.success) {
logger.log(
"[CapacitorPlatformService] File saved via Android SAF:",
{
path: result.path,
fileName: uniqueFileName,
timestamp: new Date().toISOString(),
},
);
} else {
throw new Error(`Failed to save via SAF: ${result.error}`);
}
}
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error(
"[CapacitorPlatformService] Error in saveAs:",
JSON.stringify(errLog, null, 2),
);
throw new Error(`Failed to save file: ${err.message}`);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileName(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifier();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifier(): string {
try {
const deviceInfo = this.getDeviceInfo();
return this.hashString(deviceInfo);
} catch (error) {
return 'mobile';
}
}
/**
* Gets device info string
*/
private getDeviceInfo(): string {
try {
// For mobile platforms, use device info
const capabilities = this.getCapabilities();
if (capabilities.isIOS) {
return 'ios_mobile';
} else if (capabilities.isAndroid) {
return 'android_mobile';
} else {
return 'mobile';
}
} catch (error) {
return 'mobile';
}
}
/**
* Simple hash function for device ID
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* Determines the MIME type for a given filename based on its extension.
*
* @param fileName - The filename to determine MIME type for
* @returns The MIME type string
*/
private getMimeType(fileName: string): string {
const extension = fileName.split(".").pop()?.toLowerCase();
switch (extension) {
case "json":
return "application/json";
case "txt":
return "text/plain";
case "csv":
return "text/csv";
case "pdf":
return "application/pdf";
case "xml":
return "application/xml";
case "html":
return "text/html";
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
default:
return "application/octet-stream";
}
}
/**
* Deletes a file from the app's data directory.
* @param path - Relative path to the file to delete
@@ -146,6 +146,236 @@ export class ElectronPlatformService extends CapacitorPlatformService {
return true;
}
/**
* Saves content directly to the device's Downloads folder (Electron platform).
* Uses Electron's IPC to save files directly to the Downloads directory.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
logger.info(
`[ElectronPlatformService] Using native IPC for direct file save`,
uniqueFileName,
);
// Check if we're running in Electron with the API available
if (typeof window !== "undefined" && window.electronAPI) {
// Use the native Electron IPC API for file exports
const result = await window.electronAPI.exportData(uniqueFileName, content);
if (result.success) {
logger.info(
`[ElectronPlatformService] File saved successfully to`,
result.path,
);
logger.info(
`[ElectronPlatformService] File saved to Downloads folder`,
uniqueFileName,
);
} else {
logger.error(
`[ElectronPlatformService] Native save failed`,
result.error,
);
throw new Error(`Native file save failed: ${result.error}`);
}
} else {
// Fallback to web-style download if Electron API is not available
logger.warn(
"[ElectronPlatformService] Electron API not available, falling back to web download",
);
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = uniqueFileName;
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
setTimeout(() => URL.revokeObjectURL(url), 1000);
logger.info(
`[ElectronPlatformService] Fallback download initiated`,
uniqueFileName,
);
}
} catch (error) {
logger.error("[ElectronPlatformService] File save failed", error);
throw new Error(`Failed to save file: ${error}`);
}
}
/**
* Opens the system file picker to let the user choose where to save a file (Electron platform).
* Uses Electron's IPC to show the native save dialog.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileNameElectron(fileName);
logger.info(
`[ElectronPlatformService] Using native IPC for save as dialog`,
uniqueFileName,
);
// Check if we're running in Electron with the API available
if (typeof window !== "undefined" && window.electronAPI) {
// Use the native Electron IPC API for file exports (same as saveToDevice for now)
// TODO: Implement native save dialog when available
const result = await window.electronAPI.exportData(uniqueFileName, content);
if (result.success) {
logger.info(
`[ElectronPlatformService] File saved successfully to`,
result.path,
);
logger.info(
`[ElectronPlatformService] File saved via save as`,
uniqueFileName,
);
} else {
logger.error(
`[ElectronPlatformService] Native save as failed`,
result.error,
);
throw new Error(`Native file save as failed: ${result.error}`);
}
} else {
// Fallback to web-style download if Electron API is not available
logger.warn(
"[ElectronPlatformService] Electron API not available, falling back to web download",
);
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = uniqueFileName;
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
setTimeout(() => URL.revokeObjectURL(url), 1000);
logger.info(
`[ElectronPlatformService] Fallback download initiated`,
uniqueFileName,
);
}
} catch (error) {
logger.error("[ElectronPlatformService] File save as failed", error);
throw new Error(`Failed to save file as: ${error}`);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileNameElectron(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifierElectron();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifierElectron(): string {
try {
const deviceInfo = this.getDeviceInfoElectron();
return this.hashStringElectron(deviceInfo);
} catch (error) {
return 'electron';
}
}
/**
* Gets device info string
*/
private getDeviceInfoElectron(): string {
try {
// Use machine-specific information
const os = require('os');
const hostname = os.hostname() || 'unknown';
const platform = os.platform() || 'unknown';
const arch = os.arch() || 'unknown';
// Create device info string
return `${platform}_${hostname}_${arch}`;
} catch (error) {
return 'electron';
}
}
/**
* Simple hash function for device ID
*/
private hashStringElectron(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* Checks if running on Capacitor platform.
*
+158 -7
View File
@@ -48,13 +48,15 @@ export class WebPlatformService implements PlatformService {
constructor() {
WebPlatformService.instanceCount++;
logger.debug("[WebPlatformService] Initializing web platform service");
// Use debug level logging for development mode to reduce console noise
const isDevelopment = process.env.VITE_PLATFORM === "development";
const log = isDevelopment ? logger.debug : logger.log;
log("[WebPlatformService] Initializing web platform service");
// Only initialize SharedArrayBuffer setup for web platforms
if (this.isWorker()) {
logger.debug(
"[WebPlatformService] Skipping initBackend call in worker context",
);
log("[WebPlatformService] Skipping initBackend call in worker context");
return;
}
@@ -95,9 +97,7 @@ export class WebPlatformService implements PlatformService {
}
} else {
// We're in a worker context - skip initBackend call
// Use console for critical startup message to avoid circular dependency
// eslint-disable-next-line no-console
console.log(
logger.info(
"[WebPlatformService] Skipping initBackend call in worker context",
);
}
@@ -592,6 +592,157 @@ export class WebPlatformService implements PlatformService {
}
}
/**
* Saves content directly to the device's Downloads folder (Android) or Documents folder (iOS).
* Uses MediaStore on Android API 29+ and falls back to SAF on older versions.
*
* @param fileName - The filename of the file to save
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveToDevice(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
// Web platform: Use the same download mechanism as writeAndShareFile
await this.writeAndShareFile(uniqueFileName, content);
logger.log("[WebPlatformService] File saved to device", uniqueFileName);
} catch (error) {
logger.error("[WebPlatformService] Error saving file to device", error);
throw new Error(
`Failed to save file to device: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Opens the system file picker to let the user choose where to save a file (web platform).
* Uses the browser's download mechanism with a suggested filename.
*
* @param fileName - The suggested filename for the file
* @param content - The content to write to the file
* @returns Promise that resolves when the file is saved
*/
async saveAs(fileName: string, content: string): Promise<void> {
try {
// Ensure content is valid JSON
try {
JSON.parse(content);
} catch {
throw new Error('Content must be valid JSON');
}
// Generate unique filename
const uniqueFileName = this.generateUniqueFileName(fileName);
// Web platform: Use the same download mechanism as writeAndShareFile
await this.writeAndShareFile(uniqueFileName, content);
logger.log("[WebPlatformService] File saved as", uniqueFileName);
} catch (error) {
logger.error("[WebPlatformService] Error saving file as", error);
throw new Error(
`Failed to save file as: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Generates unique filename with timestamp, hashed device ID, and counter
*/
private generateUniqueFileName(baseName: string, counter = 0): string {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
const deviceIdHash = this.getHashedDeviceIdentifier();
const counterSuffix = counter > 0 ? `_${counter}` : '';
const maxBaseLength = 45;
const truncatedBase = baseName.length > maxBaseLength
? baseName.substring(0, maxBaseLength)
: baseName;
const nameWithoutExt = truncatedBase.replace(/\.json$/i, '');
const extension = '.json';
const devicePart = `_${deviceIdHash}`;
const timestampPart = `_${timestamp}${counterSuffix}`;
const totalLength = nameWithoutExt.length + devicePart.length + timestampPart.length + extension.length;
if (totalLength > 200) {
const availableLength = 200 - devicePart.length - timestampPart.length - extension.length;
const finalBase = nameWithoutExt.substring(0, Math.max(10, availableLength));
return `${finalBase}${devicePart}${timestampPart}${extension}`;
}
return `${nameWithoutExt}${devicePart}${timestampPart}${extension}`;
}
/**
* Gets hashed device identifier
*/
private getHashedDeviceIdentifier(): string {
try {
const deviceInfo = this.getDeviceInfo();
return this.hashString(deviceInfo);
} catch (error) {
return 'web';
}
}
/**
* Gets device info string
*/
private getDeviceInfo(): string {
try {
// Use browser fingerprint or fallback
const userAgent = navigator.userAgent;
const language = navigator.language || 'unknown';
const platform = navigator.platform || 'unknown';
let browser = 'unknown';
let os = 'unknown';
if (userAgent.includes('Chrome')) browser = 'chrome';
else if (userAgent.includes('Firefox')) browser = 'firefox';
else if (userAgent.includes('Safari')) browser = 'safari';
else if (userAgent.includes('Edge')) browser = 'edge';
if (userAgent.includes('Windows')) os = 'win';
else if (userAgent.includes('Mac')) os = 'mac';
else if (userAgent.includes('Linux')) os = 'linux';
else if (userAgent.includes('Android')) os = 'android';
else if (userAgent.includes('iOS')) os = 'ios';
return `${browser}_${os}_${platform}_${language}`;
} catch (error) {
return 'web';
}
}
/**
* Simple hash function for device ID
*/
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(4, '0').substring(0, 4);
}
/**
* @see PlatformService.dbQuery
*/
+11 -5
View File
@@ -19,6 +19,7 @@
<EntityGrid
entity-type="people"
:entities="people"
:max-items="5"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -38,6 +39,7 @@
<EntityGrid
entity-type="projects"
:entities="projects"
:max-items="3"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -150,8 +152,11 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = (
entities: Contact[],
_entityType: string,
maxItems: number,
): Contact[] => {
return entities.filter((person) => person.profileImageUrl);
return entities
.filter((person) => person.profileImageUrl)
.slice(0, maxItems);
};
/**
@@ -160,6 +165,7 @@ export default class EntityGridFunctionPropTest extends Vue {
customProjectsFunction = (
entities: PlanData[],
_entityType: string,
_maxItems: number,
): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
};
@@ -194,16 +200,16 @@ export default class EntityGridFunctionPropTest extends Vue {
*/
get displayedPeopleCount(): number {
if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people").length;
return this.customPeopleFunction(this.people, "people", 5).length;
}
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
return Math.min(5, this.people.length);
}
get displayedProjectsCount(): number {
if (this.useCustomFunction) {
return this.customProjectsFunction(this.projects, "projects").length;
return this.customProjectsFunction(this.projects, "projects", 3).length;
}
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
return Math.min(7, this.projects.length);
}
}
</script>
-15
View File
@@ -970,20 +970,6 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts);
},
/**
* Load all contacts sorted by when they were added (by ID)
* Always fetches fresh data from database for consistency
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
*/
async $contactsByDateAdded(): Promise<Contact[]> {
const rawContacts = (await this.$query(
"SELECT * FROM contacts ORDER BY id DESC",
)) as ContactMaybeWithJsonStrings[];
return this.$normalizeContacts(rawContacts);
},
/**
* Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total number of contacts
@@ -2071,7 +2057,6 @@ declare module "@vue/runtime-core" {
// Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;
+4 -11
View File
@@ -91,15 +91,12 @@
<div class="text-sm overflow-hidden">
<div
data-testId="description"
class="flex items-start gap-2 overflow-hidden"
class="overflow-hidden text-ellipsis"
>
<font-awesome
icon="message"
class="fa-fw text-slate-400 flex-shrink-0 mt-1"
/>
<font-awesome icon="message" class="fa-fw text-slate-400" />
<vue-markdown
:source="claimDescription"
class="markdown-content flex-1 min-w-0"
class="markdown-content"
/>
</div>
<div class="overflow-hidden text-ellipsis">
@@ -554,7 +551,7 @@ import VueMarkdown from "vue-markdown-render";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { EmojiClaim, GenericVerifiableCredential } from "../interfaces";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -670,10 +667,6 @@ export default class ClaimView extends Vue {
return giveClaim.description || "";
}
if (this.veriClaim.claimType === "Emoji") {
return (claim as EmojiClaim).text || "";
}
// Fallback for other claim types
return (claim as { description?: string })?.description || "";
}
-4
View File
@@ -245,7 +245,6 @@ Raymer * @version 1.0.0 */
:last-viewed-claim-id="feedLastViewedClaimId"
:is-registered="isRegistered"
:active-did="activeDid"
:api-server="apiServer"
@load-claim="onClickLoadClaim"
@view-image="openImageViewer"
/>
@@ -1265,7 +1264,6 @@ export default class HomeView extends Vue {
provider,
fulfillsPlan,
providedByPlan,
record.emojiCount,
);
}
@@ -1489,14 +1487,12 @@ export default class HomeView extends Vue {
provider: Provider | undefined,
fulfillsPlan?: FulfillsPlan,
providedByPlan?: ProvidedByPlan,
emojiCount?: Record<string, number>,
): GiveRecordWithContactInfo {
return {
...record,
jwtId: record.jwtId,
fullClaim: record.fullClaim,
description: record.description || "",
emojiCount: emojiCount || {},
handleId: record.handleId,
issuerDid: record.issuerDid,
fulfillsPlanHandleId: record.fulfillsPlanHandleId,