feat: Data management

This commit is contained in:
2026-04-27 03:29:41 +02:00
parent 1b91eacb5b
commit 3858beb28e
13 changed files with 845 additions and 1 deletions

View File

@@ -97,7 +97,7 @@
{
"type": "initial",
"maximumWarning": "2.2MB",
"maximumError": "2.36MB"
"maximumError": "2.38MB"
},
{
"type": "anyComponentStyle",

View File

@@ -124,6 +124,24 @@ export interface SavedThemeFileDescriptor {
path: string;
}
export interface ExportUserDataResult {
cancelled: boolean;
exported: boolean;
filePath?: string;
}
export interface ImportUserDataResult {
backupPath?: string;
cancelled: boolean;
imported: boolean;
restartRequired: boolean;
}
export interface EraseUserDataResult {
erased: boolean;
restartRequired: boolean;
}
export interface ElectronCommand {
type: string;
payload: unknown;
@@ -165,6 +183,10 @@ export interface ElectronApi {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>;
exportUserData: () => Promise<ExportUserDataResult>;
importUserData: () => Promise<ImportUserDataResult>;
eraseUserData: () => Promise<EraseUserDataResult>;
getSavedThemesPath: () => Promise<string>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>;

View File

@@ -7,6 +7,7 @@ export type SettingsPage =
| 'notifications'
| 'voice'
| 'updates'
| 'data'
| 'debugging'
| 'server'
| 'members'

View File

@@ -0,0 +1,135 @@
<div class="space-y-6">
<section class="rounded-lg border border-border bg-card/60 p-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex items-center gap-2 text-primary">
<ng-icon
name="lucideDatabase"
class="h-5 w-5"
/>
<h4 class="text-base font-semibold text-foreground">Local data</h4>
</div>
<p class="mt-2 text-sm text-muted-foreground">
Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.
</p>
</div>
@if (restartRequired()) {
<button
type="button"
(click)="restartApp()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideRefreshCw"
class="h-4 w-4"
/>
Restart app
</button>
}
</div>
</section>
@if (!isElectron) {
<section class="rounded-lg border border-border bg-secondary/30 p-5">
<p class="text-sm text-muted-foreground">Data management is only available in the packaged Electron desktop app.</p>
</section>
} @else {
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Current data folder</h5>
<p class="mt-2 break-all rounded-lg border border-border bg-secondary/20 px-3 py-2 text-sm text-muted-foreground">
{{ dataPath() || 'Resolving data folder...' }}
</p>
</div>
<button
type="button"
(click)="openDataFolder()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideFolderOpen"
class="h-4 w-4"
/>
{{ busyAction() === 'open' ? 'Opening...' : 'Open folder' }}
</button>
</section>
<section class="grid gap-4 md:grid-cols-2">
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Export data</h5>
<p class="mt-1 text-sm text-muted-foreground">Create a portable .dat archive that can be imported on another client.</p>
</div>
<button
type="button"
(click)="exportData()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
{{ busyAction() === 'export' ? 'Exporting...' : 'Export data' }}
</button>
</div>
<div class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Import all data</h5>
<p class="mt-1 text-sm text-muted-foreground">Restore a .dat archive. Existing local data is moved to a backup folder first.</p>
</div>
<button
type="button"
(click)="importData()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideUpload"
class="h-4 w-4"
/>
{{ busyAction() === 'import' ? 'Importing...' : 'Import data' }}
</button>
</div>
</section>
<section class="space-y-4 rounded-lg border border-destructive/30 bg-destructive/10 p-5">
<div>
<h5 class="text-sm font-semibold text-foreground">Erase user data</h5>
<p class="mt-1 text-sm text-muted-foreground">Remove local app data from this device and recreate an empty database.</p>
</div>
<button
type="button"
(click)="eraseData()"
[disabled]="busyAction() !== null"
class="inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-60"
>
<ng-icon
name="lucideTrash2"
class="h-4 w-4"
/>
{{ busyAction() === 'erase' ? 'Erasing...' : 'Erase user data' }}
</button>
</section>
@if (statusMessage()) {
<section class="rounded-lg border border-primary/30 bg-primary/10 p-4">
<p class="break-words text-sm text-foreground">{{ statusMessage() }}</p>
</section>
}
@if (errorMessage()) {
<section class="rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<p class="break-words text-sm text-foreground">{{ errorMessage() }}</p>
</section>
}
}
</div>

View File

@@ -0,0 +1,137 @@
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideDatabase,
lucideDownload,
lucideFolderOpen,
lucideRefreshCw,
lucideTrash2,
lucideUpload
} from '@ng-icons/lucide';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
@Component({
selector: 'app-data-settings',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideDatabase,
lucideDownload,
lucideFolderOpen,
lucideRefreshCw,
lucideTrash2,
lucideUpload
})
],
templateUrl: './data-settings.component.html'
})
export class DataSettingsComponent {
private readonly electron = inject(ElectronBridgeService);
readonly isElectron = this.electron.isAvailable;
readonly dataPath = signal<string | null>(null);
readonly busyAction = signal<DataAction | null>(null);
readonly statusMessage = signal<string | null>(null);
readonly errorMessage = signal<string | null>(null);
readonly restartRequired = signal(false);
constructor() {
void this.loadDataPath();
}
async openDataFolder(): Promise<void> {
await this.runAction('open', async () => {
const opened = await this.electron.requireApi().openCurrentDataFolder();
this.statusMessage.set(opened ? 'Opened the current data folder.' : 'Could not open the data folder.');
});
}
async exportData(): Promise<void> {
await this.runAction('export', async () => {
const result = await this.electron.requireApi().exportUserData();
if (result.cancelled) {
this.statusMessage.set('Export cancelled.');
return;
}
this.statusMessage.set(result.filePath ? `Exported data to ${result.filePath}.` : 'Exported data.');
});
}
async importData(): Promise<void> {
if (!window.confirm('Importing data replaces the current local data. Existing data will be moved to a backup folder first. Continue?')) {
return;
}
await this.runAction('import', async () => {
const result = await this.electron.requireApi().importUserData();
if (result.cancelled) {
this.statusMessage.set('Import cancelled.');
return;
}
this.restartRequired.set(result.restartRequired);
this.statusMessage.set(result.backupPath
? `Imported data. Previous data was backed up to ${result.backupPath}.`
: 'Imported data.');
});
}
async eraseData(): Promise<void> {
if (!window.confirm('Erase all local MetoYou data on this device? This cannot be undone.')) {
return;
}
await this.runAction('erase', async () => {
const result = await this.electron.requireApi().eraseUserData();
this.restartRequired.set(result.restartRequired);
this.statusMessage.set('Local data erased. Restart the app to finish resetting the session.');
await this.loadDataPath();
});
}
async restartApp(): Promise<void> {
await this.runAction('restart', async () => {
await this.electron.requireApi().relaunchApp();
});
}
private async loadDataPath(): Promise<void> {
if (!this.isElectron) {
return;
}
try {
this.dataPath.set(await this.electron.requireApi().getAppDataPath());
} catch {
this.dataPath.set(null);
}
}
private async runAction(action: DataAction, operation: () => Promise<void>): Promise<void> {
this.busyAction.set(action);
this.errorMessage.set(null);
this.statusMessage.set(null);
try {
await operation();
} catch (error) {
this.errorMessage.set(error instanceof Error ? error.message : 'Data operation failed.');
} finally {
this.busyAction.set(null);
}
}
}

View File

@@ -144,6 +144,9 @@
@case ('updates') {
Updates
}
@case ('data') {
Data
}
@case ('debugging') {
Debugging
}
@@ -273,6 +276,15 @@
@case ('updates') {
<app-updates-settings />
}
@case ('data') {
@defer {
<app-data-settings />
} @loading {
<section class="rounded-lg border border-border bg-card/60 p-5">
<p class="text-sm text-muted-foreground">Loading data settings...</p>
</section>
}
}
@case ('debugging') {
<app-debugging-settings />
}

View File

@@ -44,6 +44,7 @@ import { BansSettingsComponent } from './bans-settings/bans-settings.component';
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
import { DataSettingsComponent } from './data-settings/data-settings.component';
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
import {
ThemeLibraryService,
@@ -63,6 +64,7 @@ import {
NotificationsSettingsComponent,
VoiceSettingsComponent,
UpdatesSettingsComponent,
DataSettingsComponent,
DebuggingSettingsComponent,
ServerSettingsComponent,
MembersSettingsComponent,
@@ -120,6 +122,7 @@ export class SettingsModalComponent {
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [