diff --git a/app/src/main/preload.ts b/app/src/main/preload.ts index 24b7af901b..dda7445478 100644 --- a/app/src/main/preload.ts +++ b/app/src/main/preload.ts @@ -4,6 +4,7 @@ import { Position } from '@holium/design-system'; import { MouseState } from '@holium/realm-presence'; import { ConduitState } from 'os/services/api'; +import { migrationPreload } from 'os/services/migration/migration.service'; import { lexiconPreload } from 'os/services/ship/lexicon.service'; import { settingsPreload } from 'os/services/ship/settings.service'; import { bazaarPreload } from 'os/services/ship/spaces/bazaar.service'; @@ -216,6 +217,7 @@ contextBridge.exposeInMainWorld('realm', realmPreload); contextBridge.exposeInMainWorld('shipService', shipPreload); contextBridge.exposeInMainWorld('spacesService', spacesPreload); contextBridge.exposeInMainWorld('authService', authPreload); +contextBridge.exposeInMainWorld('migrationService', migrationPreload); contextBridge.exposeInMainWorld('chatService', chatPreload); contextBridge.exposeInMainWorld('walletService', walletPreload); contextBridge.exposeInMainWorld('notifService', notifPreload); diff --git a/app/src/os/realm.service.ts b/app/src/os/realm.service.ts index 4ce8716bd9..72f98181d3 100644 --- a/app/src/os/realm.service.ts +++ b/app/src/os/realm.service.ts @@ -14,12 +14,14 @@ import AbstractService, { ServiceOptions } from './services/abstract.service'; import { APIConnection } from './services/api'; import { AuthService } from './services/auth/auth.service'; import OnboardingService from './services/auth/onboarding.service'; +import { MigrationService } from './services/migration/migration.service'; import { FileUploadParams, ShipService } from './services/ship/ship.service'; export class RealmService extends AbstractService { public services?: { auth: AuthService; onboarding: OnboardingService; + migration: MigrationService; ship?: ShipService; }; @@ -29,6 +31,7 @@ export class RealmService extends AbstractService { this.services = { auth: new AuthService(), onboarding: new OnboardingService(), + migration: new MigrationService(), }; this.onWebViewAttached = this.onWebViewAttached.bind(this); diff --git a/app/src/os/services/abstract.db.ts b/app/src/os/services/abstract.db.ts index a07d60817b..549510508c 100644 --- a/app/src/os/services/abstract.db.ts +++ b/app/src/os/services/abstract.db.ts @@ -76,7 +76,13 @@ abstract class AbstractDataAccess { return row ? this.mapRow(row) : null; } - public create(values: Partial): T { + /** + * + * @param values the values to insert into the table. This should also include the primary key, if not auto-incrementing + * @param pKey Optional primary key to use for the record. If not provided, the lastInsertRowId will be used to find the record + * @returns the newly created record + */ + public create(values: Partial, pKey?: number | string): T { const columns = Object.keys(values).join(', '); const placeholders = Object.keys(values) .map(() => '?') @@ -85,9 +91,15 @@ abstract class AbstractDataAccess { const stmt = this.prepare(query); const result = stmt.run(Object.values(values)); - const id = result.lastInsertRowid; - if (!id) throw new Error('Failed to create new record'); - const created = this.findOne(id as number); + if (result.changes !== 1) throw new Error('Failed to create record'); + + let created: T | null = null; + if (typeof pKey !== 'undefined') { + created = this.findOne(pKey); + } else { + const id = result.lastInsertRowid; + created = this.findOne(id as number); + } if (!created) throw new Error('Failed to create new record'); return created; } @@ -101,15 +113,30 @@ abstract class AbstractDataAccess { const result = stmt.run([...Object.values(values), pKey]); if (result.changes !== 1) throw new Error('Failed to update record'); + const updated = this.findOne(pKey); if (!updated) throw new Error('Failed to update record'); return updated; } + /** + * + * @param pKey key of the record to update or create + * @param values the table values, in proper order, including the primary key + * @returns the created or updated record + */ + public upsert(pKey: number | string, values: Partial): T { + if (this.findOne(pKey)) { + return this.update(pKey, values); + } + return this.create(values, pKey); + } + public delete(pKey: number | string): void { const query = `DELETE FROM ${this.tableName} WHERE ${this.pKey} = ?`; const stmt = this.prepare(query); - stmt.run(pKey); + const result = stmt.run(pKey); + if (result.changes !== 1) throw new Error('Failed to delete record'); } public raw(query: string, params?: any[]): any[] { diff --git a/app/src/os/services/auth/accounts.table.ts b/app/src/os/services/auth/accounts.table.ts index 3b9cb6b3dd..58152d7e0a 100644 --- a/app/src/os/services/auth/accounts.table.ts +++ b/app/src/os/services/auth/accounts.table.ts @@ -36,62 +36,13 @@ export class Accounts extends AbstractDataAccess { updatedAt: row.updatedAt, }; } - - public findOne(serverId: string): DBAccount | null { - const query = `SELECT * FROM ${this.tableName} WHERE serverId = ?`; - const stmt = this.prepare(query); - const row = stmt.get(serverId); - return row ? this.mapRow(row) : null; - } - - public findAll(accountId: number): DBAccount[] { - const query = `SELECT * FROM ${this.tableName} WHERE accountId = ?`; - const stmt = this.prepare(query); - const rows = stmt.all(accountId); - return rows.map((row) => this.mapRow(row)); - } - - public create(values: Partial): DBAccount { - const columns = Object.keys(values).join(', '); - const placeholders = Object.keys(values) - .map(() => '?') - .join(', '); - const query = `INSERT INTO ${this.tableName} (${columns}) VALUES (${placeholders})`; - const stmt = this.prepare(query); - - stmt.run(Object.values(values)); - if (!values.serverId) throw new Error('Failed to create new record'); - const created = this.findOne(values.serverId); - if (!created) throw new Error('Failed to create new record'); - return created; - } - - public update(serverId: string, values: Partial): DBAccount { - const setClause = Object.keys(values) - .map((key) => `${key} = ?`) - .join(', '); - const query = `UPDATE ${this.tableName} SET ${setClause} WHERE serverId = ?`; - const stmt = this.prepare(query); - - stmt.run([...Object.values(values), serverId]); - const updated = this.findOne(serverId); - if (!updated) throw new Error('Failed to update record'); - return updated; - } - - public delete(serverId: string): void { - const query = `DELETE FROM ${this.tableName} WHERE serverId = ?`; - const stmt = this.prepare(query); - - const result = stmt.run(serverId); - if (result.changes !== 1) throw new Error('Failed to delete record'); - } } -export const accountsInit = ` +export const accountsInitSql = ` create table if not exists accounts ( accountId INTEGER, serverId TEXT PRIMARY KEY NOT NULL, + serverCode TEXT, serverUrl TEXT NOT NULL, serverType TEXT NOT NULL DEFAULT 'local', nickname TEXT, @@ -108,3 +59,5 @@ export const accountsInit = ` ); create unique index if not exists accounts_server_id_uindex on accounts (serverId); `; + +export const accountsWipeSql = `drop table if exists accounts;`; diff --git a/app/src/os/services/auth/auth.db.ts b/app/src/os/services/auth/auth.db.ts index 81a3e475c5..09b57aaa42 100644 --- a/app/src/os/services/auth/auth.db.ts +++ b/app/src/os/services/auth/auth.db.ts @@ -1,12 +1,48 @@ import { app } from 'electron'; -import log from 'electron-log'; -import Store from 'electron-store'; import Database from 'better-sqlite3-multiple-ciphers'; -import path from 'path'; -import { Accounts, accountsInit } from './accounts.table'; -import { AuthStore } from './auth.model.old'; -import { MasterAccounts, masterAccountsInit } from './masterAccounts.table'; +import { Migration, MigrationService } from '../migration/migration.service'; +import { Accounts, accountsInitSql, accountsWipeSql } from './accounts.table'; +import { + MasterAccounts, + masterAccountsInitSql, + masterAccountsWipeSql, +} from './masterAccounts.table'; + +const initSql = ` +${accountsInitSql} +${masterAccountsInitSql} +create table if not exists accounts_order ( + serverId TEXT PRIMARY KEY NOT NULL, + idx INTEGER NOT NULL +); + +create table if not exists accounts_meta ( + seenSplash INTEGER NOT NULL DEFAULT 0, + migrated INTEGER NOT NULL DEFAULT 0, + migratedAt INTEGER +); +`; + +const wipeSql = ` +${accountsWipeSql} +${masterAccountsWipeSql} +drop table if exists accounts_order; +drop table if exists accounts_meta; +`; + +const migrations: Migration[] = [ + { + version: 1, + up: `${initSql}`, + down: `${wipeSql}`, + }, + { + version: 2, + up: `ALTER TABLE accounts DROP COLUMN serverCode;`, + down: `ALTER TABLE accounts ADD COLUMN serverCode TEXT;`, + }, +]; export class AuthDB { private readonly authDB: Database; @@ -16,17 +52,11 @@ export class AuthDB { }; constructor() { - // Open the authentication database - this.authDB = new Database( - path.join(app.getPath('userData'), 'auth.sqlite'), - {} + this.authDB = MigrationService.getInstance().setupAndMigrate( + 'auth', + migrations, + 2 ); - this.authDB.pragma('journal_mode = WAL'); - this.authDB.pragma('foreign_keys = ON'); - this.authDB.exec(initSql); - // Migration and cleanup - // TODO need to define a better migration strategy - this.removeShipCodeColumnIfExist(); this.tables = { accounts: new Accounts(this.authDB), @@ -38,29 +68,6 @@ export class AuthDB { }); } - private removeShipCodeColumnIfExist(): void { - const query = this.authDB.prepare(` - select count(*) as found from pragma_table_info('accounts') where name='serverCode' - `); - const result = query.all(); - const found: boolean = result?.[0].found > 0; - if (found) { - log.info( - 'auth.db.ts:', - 'Removing "serverCode" column from accounts table' - ); - this.authDB.prepare('ALTER TABLE accounts RENAME TO accounts_old;').run(); - this.authDB.exec(accountsInit); - this.authDB - .prepare( - 'INSERT INTO accounts (accountId, serverUrl, serverId, serverType, nickname, color, avatar, status, theme, passwordHash) SELECT accountId, serverUrl, serverId, serverType, nickname, color, avatar, status, theme, passwordHash FROM accounts_old;' - ) - - .run(); - this.authDB.prepare('DROP TABLE accounts_old;').run(); - } - } - hasSeenSplash(): boolean { const result: any = this.authDB .prepare('SELECT seenSplash FROM accounts_meta;') @@ -77,69 +84,6 @@ export class AuthDB { .run(); } - _needsMigration(): boolean { - const result: any = this.authDB - .prepare('SELECT migrated FROM accounts_meta LIMIT 1;') - .all(); - - return !(result[0]?.migrated || null); - } - - migrateJsonToSqlite(masterAccountId: number) { - try { - const oldAuth = new Store({ - name: 'realm.auth', - accessPropertiesByDotNotation: true, - defaults: AuthStore.create({ firstTime: true }), - }); - // if realm.auth is empty, don't migrate - if (!oldAuth.store || Object.keys(oldAuth.store).length === 0) { - log.info('auth.db.ts:', 'No realm.auth.json to migrate -> skipping'); - return; - } - log.info('auth.db.ts:', 'Migrating realm.auth.json to sqlite'); - const oldTheme = new Store({ - name: 'realm.auth-theme', - accessPropertiesByDotNotation: true, - }); - // loop through ships and insert into accounts table - oldAuth.store.ships.forEach((ship) => { - const theme = oldTheme.store[ship.patp]; - const query = this.authDB.prepare(` - INSERT INTO accounts (accountId, serverUrl, serverId, serverType, nickname, color, avatar, status, theme, passwordHash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); - `); - query.run( - masterAccountId, - ship.url, - ship.patp, - 'local', - ship.nickname, - ship.color, - ship.avatar, - ship.status, - theme ? JSON.stringify(theme) : JSON.stringify({}), - ship.passwordHash - ); - }); - oldAuth.store.order.forEach((serverId: string, index: number) => { - const query = this.authDB.prepare(` - REPLACE INTO accounts_order (serverId, idx) - VALUES (?, ?); - `); - query.run(serverId.replace('auth', ''), index); - }); - // TODO clear old auth store - } catch (error) { - log.error(error); - } - this.authDB - .prepare( - 'REPLACE INTO accounts_meta (migrated, migratedAt) VALUES (1, ?);' - ) - .run(Date.now()); - } - public addToOrder(serverId: string): void { const query = this.authDB.prepare(` REPLACE INTO accounts_order (serverId, idx) @@ -175,18 +119,3 @@ export class AuthDB { this.authDB.close(); } } - -const initSql = ` -${accountsInit} -${masterAccountsInit} -create table if not exists accounts_order ( - serverId TEXT PRIMARY KEY NOT NULL, - idx INTEGER NOT NULL -); - -create table if not exists accounts_meta ( - seenSplash INTEGER NOT NULL DEFAULT 0, - migrated INTEGER NOT NULL DEFAULT 0, - migratedAt INTEGER -); -`; diff --git a/app/src/os/services/auth/masterAccounts.table.ts b/app/src/os/services/auth/masterAccounts.table.ts index c1b3f27110..2b359699d8 100644 --- a/app/src/os/services/auth/masterAccounts.table.ts +++ b/app/src/os/services/auth/masterAccounts.table.ts @@ -31,7 +31,7 @@ export class MasterAccounts extends AbstractDataAccess { } } -export const masterAccountsInit = ` +export const masterAccountsInitSql = ` create table if not exists master_accounts ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, @@ -41,3 +41,5 @@ export const masterAccountsInit = ` ); create unique index if not exists master_email_uindex on master_accounts (email); `; + +export const masterAccountsWipeSql = `drop table if exists master_accounts;`; diff --git a/app/src/os/services/auth/onboarding.service.ts b/app/src/os/services/auth/onboarding.service.ts index 0eb98a324c..edcc1d918b 100644 --- a/app/src/os/services/auth/onboarding.service.ts +++ b/app/src/os/services/auth/onboarding.service.ts @@ -70,8 +70,6 @@ export class OnboardingService extends AbstractService { }); if (newAccount) { - //if (this.authDB._needsMigration()) this.authDB.migrateJsonToSqlite(newAccount.id); - log.info( 'auth.service.ts:', `Created master account for ${masterAccountPayload.email}` @@ -117,19 +115,22 @@ export class OnboardingService extends AbstractService { return { account: existing, masterAccount }; } - const newAccount = this.authDB.tables.accounts.create({ - accountId: acc.accountId, - serverId: acc.serverId, - serverUrl: acc.serverUrl, - serverType: acc.serverType, - avatar: acc.avatar, - nickname: acc.nickname, - description: acc.description, - color: acc.color, - status: acc.status, - theme: acc.theme, - passwordHash: masterAccount?.passwordHash, - }); + const newAccount = this.authDB.tables.accounts.create( + { + accountId: acc.accountId, + serverId: acc.serverId, + serverUrl: acc.serverUrl, + serverType: acc.serverType, + avatar: acc.avatar, + nickname: acc.nickname, + description: acc.description, + color: acc.color, + status: acc.status, + theme: acc.theme, + passwordHash: masterAccount?.passwordHash, + }, + acc.serverId + ); this.authDB.addToOrder(acc.serverId); const cookie = await this.getCookie({ @@ -205,7 +206,9 @@ export class OnboardingService extends AbstractService { return false; } - const accounts = this.authDB.tables.accounts.findAll(masterAccount.id); + const accounts = this.authDB.tables.accounts.find( + `accountId = ${masterAccount.id}` + ); if (!accounts || accounts.length === 0) { log.error( 'auth.service.ts:', diff --git a/app/src/os/services/migration/migration.db.ts b/app/src/os/services/migration/migration.db.ts new file mode 100644 index 0000000000..6d1c98f6ff --- /dev/null +++ b/app/src/os/services/migration/migration.db.ts @@ -0,0 +1,39 @@ +import { app } from 'electron'; +import Database from 'better-sqlite3-multiple-ciphers'; + +import { Migration, MigrationService } from './migration.service'; +import { + SchemaVersions, + schemaVersionsInitSql, + schemaVersionsWipeSql, +} from './schemaVersions.table'; + +const migrations: Migration[] = [ + { + version: 1, + up: `${schemaVersionsInitSql}`, + down: `${schemaVersionsWipeSql}`, + }, +]; + +export class MigrationDB { + private readonly migrationDB: Database; + tables: { + schemaVersions: SchemaVersions; + }; + + constructor(instance: MigrationService) { + this.migrationDB = instance.setupAndMigrate('migration', migrations, 1); + this.tables = { + schemaVersions: new SchemaVersions(this.migrationDB), + }; + + app.on('quit', () => { + this.disconnect(); + }); + } + + disconnect() { + this.migrationDB.close(); + } +} diff --git a/app/src/os/services/migration/migration.service.ts b/app/src/os/services/migration/migration.service.ts new file mode 100644 index 0000000000..6ffc92aacf --- /dev/null +++ b/app/src/os/services/migration/migration.service.ts @@ -0,0 +1,165 @@ +import { app } from 'electron'; +import log from 'electron-log'; +import Database from 'better-sqlite3-multiple-ciphers'; +import path from 'path'; + +import AbstractService, { ServiceOptions } from '../abstract.service'; +import { MigrationDB } from './migration.db'; + +export interface Migration { + version: number; + up: string; + down: string; +} + +export class MigrationService extends AbstractService { + private static instance: MigrationService; + private readonly migrationDB?: MigrationDB; + constructor(options?: ServiceOptions) { + super('migrationService', options); + if (options?.preload) { + return; + } + this.migrationDB = new MigrationDB(this); + } + + public static getInstance(options?: ServiceOptions): MigrationService { + if (!MigrationService.instance) { + MigrationService.instance = new MigrationService(options); + } + return MigrationService.instance; + } + + public getSchemaVersion(tableName: string): number { + if (!this.migrationDB) return 0; + const record = this.migrationDB.tables.schemaVersions.findOne(tableName); + if (!record) { + this.setSchemaVersion(tableName, 0); + return 0; + } + return record.version; + } + + public setSchemaVersion(tableName: string, version: number) { + if (!this.migrationDB) return; + return this.migrationDB.tables.schemaVersions.upsert(tableName, { + table_name: tableName, + version, + }); + } + + private executeStatement(db: Database, statement: string) { + try { + db.prepare(statement).run(); + } catch (error: any) { + if ( + error.message.includes('duplicate column name') || + error.message.includes('no such column') + ) { + log.info( + 'MigrationService.executeStatement', + 'ignoring error: ', + error.message, + 'for statement: ', + statement + ); + return; + } else { + throw error; + } + } + } + + // run migrations as a transaction based on the current table version + public setupAndMigrate( + tableName: string, + migrations: Migration[], + targetVersion: number, + password?: string + ): Database { + const dbPath = path.join(app.getPath('userData'), tableName + '.sqlite'); + const db = new Database(dbPath, { password }); + + // default actions + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + const currentVersion = this.getSchemaVersion(tableName); + + if (targetVersion > currentVersion) { + for (const migration of migrations) { + if ( + migration.version > currentVersion && + migration.version <= targetVersion + ) { + // Run all statements in a transaction + // These have to be broken into separate statements based on semicolon. + const statements = migration.up + .split(';') + .filter((s) => s.trim() !== ''); + const transact = db.transaction((statements: string[]) => { + for (const statement of statements) { + this.executeStatement(db, statement); + } + this.setSchemaVersion(tableName, migration.version); + }); + try { + transact(statements); + log.info( + 'migration.service.ts:', + `Up migration to version ${migration.version} for database '${tableName}' applied.` + ); + } catch (error) { + log.error( + 'migration.service.ts:', + `Up migration to version ${migration.version} for database '${tableName}' failed.` + ); + throw error; + } + } + } + } else if (targetVersion < currentVersion) { + // Perform down migrations in reverse order + for (const migration of migrations.slice().reverse()) { + if ( + migration.version <= currentVersion && + migration.version > targetVersion + ) { + // Run all statements in a transaction + // These have to be broken into separate statements based on semicolon. + const statements = migration.down + .split(';') + .filter((s) => s.trim() !== ''); + const transact = db.transaction((statements: string[]) => { + for (const statement of statements) { + this.executeStatement(db, statement); + } + this.setSchemaVersion(tableName, migration.version - 1); + }); + try { + transact(statements); + log.info( + 'migration.service.ts:', + `Down migration to version ${ + migration.version - 1 + } for table '${tableName}' applied.` + ); + } catch (error) { + log.info( + 'migration.service.ts:', + `Down migration to version ${ + migration.version - 1 + } for table '${tableName}' failed.` + ); + throw error; + } + } + } + } + return db; + } +} +// Generate preload +export const migrationPreload = MigrationService.preload( + new MigrationService({ preload: true }) +); diff --git a/app/src/os/services/migration/schemaVersions.table.ts b/app/src/os/services/migration/schemaVersions.table.ts new file mode 100644 index 0000000000..985416fd24 --- /dev/null +++ b/app/src/os/services/migration/schemaVersions.table.ts @@ -0,0 +1,36 @@ +import Database from 'better-sqlite3-multiple-ciphers'; + +import AbstractDataAccess from '../abstract.db'; + +export interface SchemaVersion { + tableName: string; + version: number; +} + +export class SchemaVersions extends AbstractDataAccess { + constructor(db: Database) { + super({ + preload: false, + db, + name: 'schemaVersions', + tableName: 'schema_versions', + pKey: 'table_name', + }); + } + + protected mapRow(row: any): SchemaVersion { + return { + tableName: row.table_name, + version: row.version, + }; + } +} + +export const schemaVersionsInitSql = ` +create table if not exists schema_versions ( + table_name TEXT PRIMARY KEY NOT NULL, + version INTEGER NOT NULL DEFAULT 0 +); +`; + +export const schemaVersionsWipeSql = `drop table if exists schema_versions;`; diff --git a/app/src/os/services/ship/chat/chat.schema.ts b/app/src/os/services/ship/chat/chat.schema.ts index a657f352a5..a010b48d1c 100644 --- a/app/src/os/services/ship/chat/chat.schema.ts +++ b/app/src/os/services/ship/chat/chat.schema.ts @@ -19,8 +19,7 @@ create table if not exists ${CHAT_TABLES.MESSAGES} sender text NOT NULL, updated_at INTEGER NOT NULL, created_at INTEGER NOT NULL, - expires_at INTEGER, - received_at INTEGER NOT NULL + expires_at INTEGER ); create unique index if not exists ${CHAT_TABLES.MESSAGES}_path_msg_id_msg_part_id_uindex @@ -39,8 +38,7 @@ create table if not exists ${CHAT_TABLES.PATHS} pinned INTEGER default 0 NOT NULL, muted INTEGER default 0 NOT NULL, updated_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - received_at INTEGER NOT NULL + created_at INTEGER NOT NULL ); create unique index if not exists ${CHAT_TABLES.PATHS}_path_uindex @@ -59,8 +57,7 @@ create table if not exists ${CHAT_TABLES.PEERS} ship text NOT NULL, role TEXT default 'member' NOT NULL, updated_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - received_at INTEGER NOT NULL + created_at INTEGER NOT NULL ); create unique index if not exists ${CHAT_TABLES.PEERS}_path_ship_uindex @@ -76,3 +73,11 @@ create unique index if not exists ${CHAT_TABLES.DELETE_LOGS}_change_uindex on ${CHAT_TABLES.DELETE_LOGS} (timestamp, change); `; + +export const chatWipeSql = ` +drop table if exists ${CHAT_TABLES.MESSAGES}; +drop table if exists ${CHAT_TABLES.PATHS}; +drop table if exists ${CHAT_TABLES.PEERS}; +drop table if exists ${CHAT_TABLES.PATHS_FLAGS}; +drop table if exists ${CHAT_TABLES.DELETE_LOGS}; +`; diff --git a/app/src/os/services/ship/friends.service.ts b/app/src/os/services/ship/friends.service.ts index f42471bc4f..0815908c46 100644 --- a/app/src/os/services/ship/friends.service.ts +++ b/app/src/os/services/ship/friends.service.ts @@ -197,49 +197,6 @@ export class FriendsService extends AbstractDataAccess { return APIConnection.getInstance().conduit.poke(payload); } - - public findOne(patp: string): Friend | null { - const query = `SELECT * FROM ${this.tableName} WHERE patp = ?`; - const stmt = this.prepare(query); - const row = stmt.get(patp); - return row ? this.mapRow(row) : null; - } - - public create(values: Partial): Friend { - const columns = Object.keys(values).join(', '); - const placeholders = Object.keys(values) - .map(() => '?') - .join(', '); - const query = `INSERT INTO ${this.tableName} (${columns}) VALUES (${placeholders})`; - const stmt = this.prepare(query); - - stmt.run(Object.values(values)); - if (!values.patp) throw new Error('Failed to create new record'); - const created = this.findOne(values.patp); - if (!created) throw new Error('Failed to create new record'); - return created; - } - - public update(patp: string, values: Partial): Friend { - const setClause = Object.keys(values) - .map((key) => `${key} = ?`) - .join(', '); - const query = `UPDATE ${this.tableName} SET ${setClause} WHERE patp = ?`; - const stmt = this.prepare(query); - - stmt.run([...Object.values(values), patp]); - const updated = this.findOne(patp); - if (!updated) throw new Error('Failed to update record'); - return updated; - } - - public delete(patp: string): void { - const query = `DELETE FROM ${this.tableName} WHERE patp = ?`; - const stmt = this.prepare(query); - - const result = stmt.run(patp); - if (result.changes !== 1) throw new Error('Failed to delete record'); - } } export const friendsInitSql = ` @@ -257,6 +214,10 @@ export const friendsInitSql = ` create unique index if not exists friends_patp_uindex on friends (patp); `; +export const friendsWipeSql = ` +drop table if exists friends; +`; + export const friendsPreload = FriendsService.preload( new FriendsService({ preload: true }) ); diff --git a/app/src/os/services/ship/lexicon.tables.ts b/app/src/os/services/ship/lexicon.tables.ts index 98b2e8c14d..343d4eba32 100644 --- a/app/src/os/services/ship/lexicon.tables.ts +++ b/app/src/os/services/ship/lexicon.tables.ts @@ -68,3 +68,10 @@ ${wordInitSql} ${definitionInitSql} ${sentenceInitSql} `; + +export const lexiconWipeSql = ` +drop table if exists votes; +drop table if exists lexicon_words; +drop table if exists lexicon_definitions; +drop table if exists lexicon_sentences; +`; diff --git a/app/src/os/services/ship/notifications/notifications.table.ts b/app/src/os/services/ship/notifications/notifications.table.ts index f1acaf4a34..22d464bd4d 100644 --- a/app/src/os/services/ship/notifications/notifications.table.ts +++ b/app/src/os/services/ship/notifications/notifications.table.ts @@ -439,6 +439,11 @@ create table if not exists notifications_delete_logs ); `; +export const notifWipeSql = ` +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS notifications_delete_logs; +`; + export const QUERY_NOTIFICATIONS = ` SELECT id, app, diff --git a/app/src/os/services/ship/settings.service.ts b/app/src/os/services/ship/settings.service.ts index 3878f4aebb..5ca859e8a3 100644 --- a/app/src/os/services/ship/settings.service.ts +++ b/app/src/os/services/ship/settings.service.ts @@ -8,6 +8,8 @@ export type Setting = { isolationModeEnabled: number; realmCursorEnabled: number; profileColorForCursorEnabled: number; + standaloneChatSpaceWallpaperEnabled: number; + standaloneChatPersonalWallpaperEnabled: number; }; export class SettingsService extends AbstractDataAccess { @@ -26,6 +28,10 @@ export class SettingsService extends AbstractDataAccess { isolationModeEnabled: row.isolationModeEnabled, realmCursorEnabled: row.realmCursorEnabled, profileColorForCursorEnabled: row.profileColorForCursorEnabled, + standaloneChatSpaceWallpaperEnabled: + row.standaloneChatSpaceWallpaperEnabled, + standaloneChatPersonalWallpaperEnabled: + row.standaloneChatPersonalWallpaperEnabled, }; } @@ -45,7 +51,21 @@ export class SettingsService extends AbstractDataAccess { set(setting: Setting) { if (!this.db?.open) return; const replace = this.db.prepare( - `REPLACE INTO settings (identity, isolationModeEnabled, realmCursorEnabled, profileColorForCursorEnabled) VALUES (@identity, @isolationModeEnabled, @realmCursorEnabled, @profileColorForCursorEnabled)` + `REPLACE INTO settings ( + identity, + isolationModeEnabled, + realmCursorEnabled, + profileColorForCursorEnabled, + standaloneChatSpaceWallpaperEnabled, + standaloneChatPersonalWallpaperEnabled + ) VALUES ( + @identity, + @isolationModeEnabled, + @realmCursorEnabled, + @profileColorForCursorEnabled, + @standaloneChatSpaceWallpaperEnabled, + @standaloneChatPersonalWallpaperEnabled + )` ); replace.run(setting); } @@ -68,6 +88,8 @@ export const settingsInitSql = ` ); `; +export const settingsWipeSql = `drop table if exists settings;`; + export const settingsPreload = SettingsService.preload( new SettingsService({ preload: true }) ); diff --git a/app/src/os/services/ship/ship.db.ts b/app/src/os/services/ship/ship.db.ts index 9f4e1f7ed9..7ca19313a3 100644 --- a/app/src/os/services/ship/ship.db.ts +++ b/app/src/os/services/ship/ship.db.ts @@ -1,25 +1,126 @@ -import { app } from 'electron'; -import log from 'electron-log'; import Database from 'better-sqlite3-multiple-ciphers'; -import path from 'path'; -import { CHAT_TABLES, chatInitSql } from './chat/chat.schema'; -import { friendsInitSql } from './friends.service'; -import { lexiconInitSql } from './lexicon.tables'; -import { notifInitSql } from './notifications/notifications.table'; -import { settingsInitSql } from './settings.service'; +import { Migration, MigrationService } from '../migration/migration.service'; +import { CHAT_TABLES, chatInitSql, chatWipeSql } from './chat/chat.schema'; +import { friendsInitSql, friendsWipeSql } from './friends.service'; +import { lexiconInitSql, lexiconWipeSql } from './lexicon.tables'; +import { + notifInitSql, + notifWipeSql, +} from './notifications/notifications.table'; +import { settingsInitSql, settingsWipeSql } from './settings.service'; import { Credentials } from './ship.types.ts'; -import { spacesTablesInitSql } from './spaces/spaces.service'; -import { appPublishersInitSql } from './spaces/tables/appPublishers.table'; -import { appRecentsInitSql } from './spaces/tables/appRecents.table'; -import { bazaarTablesInitSql } from './spaces/tables/catalog.table'; -import { walletInitSql } from './wallet/wallet.db'; +import { + spacesTablesInitSql, + spacesTablesWipeSql, +} from './spaces/spaces.service'; +import { + appPublishersInitSql, + appPublishersWipeSql, +} from './spaces/tables/appPublishers.table'; +import { + appRecentsInitSql, + appRecentsWipeSql, +} from './spaces/tables/appRecents.table'; +import { + bazaarTablesInitSql, + bazaarTablesWipeSql, +} from './spaces/tables/catalog.table'; +import { walletInitSql, walletWipeSql } from './wallet/wallet.db'; + +const initSql = ` +${bazaarTablesInitSql} +${lexiconInitSql} +${chatInitSql} +${notifInitSql} +${friendsInitSql} +${spacesTablesInitSql} +${walletInitSql} +${appPublishersInitSql} +${appRecentsInitSql} +${settingsInitSql} +create table if not exists credentials ( + url TEXT PRIMARY KEY NOT NULL, + code TEXT NOT NULL, + cookie TEXT NOT NULL, + wallet TEXT +); +`; + +const wipeSql = ` + ${bazaarTablesWipeSql} + ${lexiconWipeSql} + ${chatWipeSql} + ${notifWipeSql} + ${friendsWipeSql} + ${spacesTablesWipeSql} + ${walletWipeSql} + ${appPublishersWipeSql} + ${appRecentsWipeSql} + ${settingsWipeSql} + drop table if exists credentials; +`; + +const migrations: Migration[] = [ + { + version: 1, + up: `${initSql}`, + down: `${wipeSql}`, + }, + { + version: 2, + up: ` + alter table ${CHAT_TABLES.MESSAGES} add column received_at INTEGER NOT NULL DEFAULT 0; + alter table ${CHAT_TABLES.PEERS} add column received_at INTEGER NOT NULL DEFAULT 0; + alter table ${CHAT_TABLES.PATHS} add column received_at INTEGER NOT NULL DEFAULT 0; + + create table if not exists credentials_temp ( + url TEXT PRIMARY KEY NOT NULL, + code TEXT NOT NULL, + cookie TEXT, + wallet TEXT + ); + INSERT INTO credentials_temp(url, code, cookie, wallet) + SELECT url, code, cookie, wallet + FROM credentials; + DROP TABLE credentials; + ALTER TABLE credentials_temp RENAME TO credentials; + `, + down: ` + alter table ${CHAT_TABLES.MESSAGES} drop column received_at; + alter table ${CHAT_TABLES.PEERS} drop column received_at; + alter table ${CHAT_TABLES.PATHS} drop column received_at; + + create table if not exists credentials_temp ( + url TEXT PRIMARY KEY NOT NULL, + code TEXT NOT NULL, + cookie TEXT NOT NULL, + wallet TEXT + ); + INSERT INTO credentials_temp(url, code, cookie, wallet) + SELECT url, code, COALESCE(cookie, ''), wallet + FROM credentials; + DROP TABLE credentials; + ALTER TABLE credentials_temp RENAME TO credentials; + `, + }, + { + version: 3, + up: ` + ALTER TABLE settings ADD COLUMN standaloneChatSpaceWallpaperEnabled number DEFAULT 1; + ALTER TABLE settings ADD COLUMN standaloneChatPersonalWallpaperEnabled number DEFAULT 0; + `, + down: ` + ALTER TABLE settings DROP COLUMN standaloneChatSpaceWallpaperEnabled; + ALTER TABLE settings DROP COLUMN standaloneChatPersonalWallpaperEnabled; + `, + }, +]; export class ShipDB { private shipDB: Database; private patp: string; - private readonly dbPath: string; // private readonly dontEncryptDb: boolean = // process.env.DONT_ENCRYPT_DB === 'true'; @@ -29,14 +130,12 @@ export class ShipDB { _clientSideEncryptionKey: string ) { this.patp = patp; - this.dbPath = path.join(app.getPath('userData'), `${patp}.sqlite`); - - // Create the database if it doesn't exist - - this.shipDB = new Database(this.dbPath); - this.shipDB.exec(initSql); - - // } else { + this.shipDB = MigrationService.getInstance().setupAndMigrate( + this.patp, + migrations, + 3 + ); + // TODO: re-enable encryption // log.info('ship.db.ts:', 'Encrypting ship db'); // const hashGenerator = crypto.createHmac( // 'sha256', @@ -49,39 +148,6 @@ export class ShipDB { // }); // this.shipDB.exec(initSql); // } - - // update db schemas if we need to - this.addColumnIfNotExists( - CHAT_TABLES.MESSAGES, - 'received_at', - 'INTEGER NOT NULL DEFAULT 0' - ); - this.addColumnIfNotExists( - CHAT_TABLES.PEERS, - 'received_at', - 'INTEGER NOT NULL DEFAULT 0' - ); - this.addColumnIfNotExists( - CHAT_TABLES.PATHS, - 'received_at', - 'INTEGER NOT NULL DEFAULT 0' - ); - - // e.g. make cookie nullable in credentials table - log.info('ship.db.ts:', 'Running upgrade script'); - this.shipDB.exec(upgradeScript); - } - - private addColumnIfNotExists(table: string, column: string, type: string) { - const queryResult = this.shipDB - .prepare( - `select count(*) as found from pragma_table_info('${table}') where name='${column}'` - ) - .all(); - const found: boolean = queryResult?.[0].found > 0; - if (!found) { - this.shipDB.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type};`); - } } get db() { @@ -128,38 +194,3 @@ export class ShipDB { this.shipDB.close(); } } - -const initSql = ` -${bazaarTablesInitSql} -${chatInitSql} -${notifInitSql} -${friendsInitSql} -${spacesTablesInitSql} -${walletInitSql} -${appPublishersInitSql} -${appRecentsInitSql} -${settingsInitSql} -${lexiconInitSql} -create table if not exists credentials ( - url TEXT PRIMARY KEY NOT NULL, - code TEXT NOT NULL, - cookie TEXT NOT NULL, - wallet TEXT -); -`; - -const upgradeScript = ` -BEGIN TRANSACTION; -create table if not exists credentials_temp ( - url TEXT PRIMARY KEY NOT NULL, - code TEXT NOT NULL, - cookie TEXT, - wallet TEXT -); -INSERT INTO credentials_temp(url, code, cookie, wallet) -SELECT url, code, cookie, wallet -FROM credentials; -DROP TABLE credentials; -ALTER TABLE credentials_temp RENAME TO credentials; -COMMIT TRANSACTION; -`; diff --git a/app/src/os/services/ship/spaces/spaces.service.ts b/app/src/os/services/ship/spaces/spaces.service.ts index 22371c4b77..6242868e48 100644 --- a/app/src/os/services/ship/spaces/spaces.service.ts +++ b/app/src/os/services/ship/spaces/spaces.service.ts @@ -13,14 +13,24 @@ import { Bookmark, BookmarksDB, bookmarksInitSql, + bookmarksWipeSql, } from './tables/bookmarks.table'; import { FeaturedSpacesDB, spacesFeaturedInitSql, + spacesFeaturedWipeSql, } from './tables/featured.table'; -import { MembersDB, spacesMembersInitSql } from './tables/members.table'; -import { SpacesDB, spacesInitSql } from './tables/spaces.table'; -import { InvitationDB, spacesInvitationsInitSql } from './tables/visas.table'; +import { + MembersDB, + spacesMembersInitSql, + spacesMembersWipeSql, +} from './tables/members.table'; +import { SpacesDB, spacesInitSql, spacesWipeSql } from './tables/spaces.table'; +import { + InvitationDB, + spacesInvitationsInitSql, + spacesInvitationsWipeSql, +} from './tables/visas.table'; export class SpacesService extends AbstractService { private shipDB?: Database; @@ -615,6 +625,14 @@ export const spacesTablesInitSql = ` ${bookmarksInitSql} `; +export const spacesTablesWipeSql = ` + ${spacesWipeSql} + ${spacesInvitationsWipeSql} + ${spacesMembersWipeSql} + ${spacesFeaturedWipeSql} + ${bookmarksWipeSql} +`; + export type NewSpace = { name: string; description: string; diff --git a/app/src/os/services/ship/spaces/tables/appPublishers.table.ts b/app/src/os/services/ship/spaces/tables/appPublishers.table.ts index a73a4b7e36..0419de040a 100644 --- a/app/src/os/services/ship/spaces/tables/appPublishers.table.ts +++ b/app/src/os/services/ship/spaces/tables/appPublishers.table.ts @@ -240,6 +240,8 @@ export const appPublishersInitSql = ` create unique index if not exists ${tableName}_uindex on ${tableName} (publisher, source, desk); `; +export const appPublishersWipeSql = `drop table if exists ${tableName};`; + export const appPublishersDBPreload = AppPublishersTable.preload( new AppPublishersTable(true) ); diff --git a/app/src/os/services/ship/spaces/tables/appRecents.table.ts b/app/src/os/services/ship/spaces/tables/appRecents.table.ts index 19ac1e41f7..eed791acd6 100644 --- a/app/src/os/services/ship/spaces/tables/appRecents.table.ts +++ b/app/src/os/services/ship/spaces/tables/appRecents.table.ts @@ -109,6 +109,8 @@ export const appRecentsInitSql = ` create unique index if not exists ${tableName}_uindex on ${tableName} (publisher, desk, type); `; +export const appRecentsWipeSql = `drop table if exists ${tableName};`; + export const appRecentsPreload = AppRecentsTable.preload( new AppRecentsTable(true) ); diff --git a/app/src/os/services/ship/spaces/tables/bookmarks.table.ts b/app/src/os/services/ship/spaces/tables/bookmarks.table.ts index 5508bb00db..a4f33fe70c 100644 --- a/app/src/os/services/ship/spaces/tables/bookmarks.table.ts +++ b/app/src/os/services/ship/spaces/tables/bookmarks.table.ts @@ -74,4 +74,6 @@ export const bookmarksInitSql = ` create unique index if not exists bookmarks_path_url on bookmarks (path, url); `; +export const bookmarksWipeSql = `drop table if exists bookmarks;`; + export const bookmarksDBPreload = BookmarksDB.preload(new BookmarksDB(true)); diff --git a/app/src/os/services/ship/spaces/tables/catalog.table.ts b/app/src/os/services/ship/spaces/tables/catalog.table.ts index 156b87ed28..b4f28b2aa6 100644 --- a/app/src/os/services/ship/spaces/tables/catalog.table.ts +++ b/app/src/os/services/ship/spaces/tables/catalog.table.ts @@ -464,3 +464,11 @@ create table if not exists spaces_stalls ( create unique index if not exists spaces_stalls_uindex on spaces_stalls (space); `; + +export const bazaarTablesWipeSql = ` +drop table if exists app_catalog; +drop table if exists app_grid; +drop table if exists app_docks; +drop table if exists app_recommendations; +drop table if exists spaces_stalls; +`; diff --git a/app/src/os/services/ship/spaces/tables/featured.table.ts b/app/src/os/services/ship/spaces/tables/featured.table.ts index 4571e9e99d..283c6b4954 100644 --- a/app/src/os/services/ship/spaces/tables/featured.table.ts +++ b/app/src/os/services/ship/spaces/tables/featured.table.ts @@ -133,6 +133,8 @@ export const spacesFeaturedInitSql = ` create unique index if not exists spaces_featured_uindex on spaces_featured (path); `; +export const spacesFeaturedWipeSql = `drop table if exists spaces_featured;`; + export const spacesFeaturedDBPreload = FeaturedSpacesDB.preload( new FeaturedSpacesDB(true) ); diff --git a/app/src/os/services/ship/spaces/tables/members.table.ts b/app/src/os/services/ship/spaces/tables/members.table.ts index 66bf211e8e..97c08052ce 100644 --- a/app/src/os/services/ship/spaces/tables/members.table.ts +++ b/app/src/os/services/ship/spaces/tables/members.table.ts @@ -119,43 +119,6 @@ export class MembersDB extends AbstractDataAccess { const stmt = this.prepare(query); stmt.run(path, patp); } - - public create(values: Partial): Member { - if (values.roles) values.roles = JSON.stringify(values.roles); - const columns = Object.keys(values).join(', '); - const placeholders = Object.keys(values) - .map(() => '?') - .join(', '); - const query = `INSERT INTO ${this.tableName} (${columns}) VALUES (${placeholders})`; - const stmt = this.prepare(query); - - stmt.run(Object.values(values)); - if (!values.space) throw new Error('Failed to create new record'); - const created = this.findOne(values.space); - if (!created) throw new Error('Failed to create new record'); - return created; - } - - public update(path: string, values: Partial): Member { - const setClause = Object.keys(values) - .map((key) => `${key} = ?`) - .join(', '); - const query = `UPDATE ${this.tableName} SET ${setClause} WHERE path = ?`; - const stmt = this.prepare(query); - - stmt.run([...Object.values(values), path]); - const updated = this.findOne(path); - if (!updated) throw new Error('Failed to update record'); - return updated; - } - - public delete(path: string): void { - const query = `DELETE FROM ${this.tableName} WHERE path = ?`; - const stmt = this.prepare(query); - - const result = stmt.run(path); - if (result.changes !== 1) throw new Error('Failed to delete record'); - } } export const spacesMembersInitSql = ` @@ -169,4 +132,6 @@ export const spacesMembersInitSql = ` create unique index if not exists spaces_members_patp_uindex on spaces_members (space, patp); `; +export const spacesMembersWipeSql = `drop table if exists spaces_members;`; + export const spacesMembersDBPreload = MembersDB.preload(new MembersDB(true)); diff --git a/app/src/os/services/ship/spaces/tables/spaces.table.ts b/app/src/os/services/ship/spaces/tables/spaces.table.ts index 4132695b55..ffa154dc7b 100644 --- a/app/src/os/services/ship/spaces/tables/spaces.table.ts +++ b/app/src/os/services/ship/spaces/tables/spaces.table.ts @@ -92,6 +92,13 @@ export class SpacesDB extends AbstractDataAccess { }); } + public getCurrent(): Space | null { + const query = `SELECT * FROM ${this.tableName} WHERE current = 1 LIMIT 1`; + const stmt = this.prepare(query); + const row = stmt.get(); + return row ? this.mapRow(row) : null; + } + public setCurrent(path: string) { // update all to 0 and then set the one to 1 in one transaction const query = ` @@ -107,56 +114,9 @@ export class SpacesDB extends AbstractDataAccess { stmt.run(path); } - public findOne(path: string): Space | null { - const query = `SELECT * FROM ${this.tableName} WHERE path = ?`; - const stmt = this.prepare(query); - const row = stmt.get(path); - return row ? this.mapRow(row) : null; - } - - public getCurrent(): Space | null { - const query = `SELECT * FROM ${this.tableName} WHERE current = 1 LIMIT 1`; - const stmt = this.prepare(query); - const row = stmt.get(); - return row ? this.mapRow(row) : null; - } - - public create(values: Partial): Space { - const columns = Object.keys(values).join(', '); - const placeholders = Object.keys(values) - .map(() => '?') - .join(', '); - const query = `INSERT INTO ${this.tableName} (${columns}) VALUES (${placeholders})`; - const stmt = this.prepare(query); - - stmt.run(Object.values(values)); - if (!values.path) throw new Error('Failed to create new record'); - const created = this.findOne(values.path); - if (!created) throw new Error('Failed to create new record'); - return created; - } - public update(path: string, values: Partial): Space { if (values.theme) values.theme = JSON.stringify(values.theme); - console.log('update', path, values); - const setClause = Object.keys(values) - .map((key) => `${key} = ?`) - .join(', '); - const query = `UPDATE ${this.tableName} SET ${setClause} WHERE path = ?`; - const stmt = this.prepare(query); - - stmt.run([...Object.values(values), path]); - const updated = this.findOne(path); - if (!updated) throw new Error('Failed to update record'); - return updated; - } - - public delete(path: string): void { - const query = `DELETE FROM ${this.tableName} WHERE path = ?`; - const stmt = this.prepare(query); - - const result = stmt.run(path); - if (result.changes !== 1) throw new Error('Failed to delete record'); + return super.update(path, values); } } @@ -177,4 +137,6 @@ export const spacesInitSql = ` create unique index if not exists spaces_path_uindex on spaces (path); `; +export const spacesWipeSql = `drop table if exists spaces;`; + export const spacesDBPreload = SpacesDB.preload(new SpacesDB(true)); diff --git a/app/src/os/services/ship/spaces/tables/visas.table.ts b/app/src/os/services/ship/spaces/tables/visas.table.ts index 80e8fbba8a..2cd83b0f13 100644 --- a/app/src/os/services/ship/spaces/tables/visas.table.ts +++ b/app/src/os/services/ship/spaces/tables/visas.table.ts @@ -116,6 +116,8 @@ export const spacesInvitationsInitSql = ` create unique index if not exists spaces_invitations_uindex on spaces_invitations (path); `; +export const spacesInvitationsWipeSql = `drop table if exists spaces_invitations;`; + export const spacesMembersDBPreload = InvitationDB.preload( new InvitationDB(true) ); diff --git a/app/src/os/services/ship/wallet/wallet.db.ts b/app/src/os/services/ship/wallet/wallet.db.ts index 8cab2ffd5e..30328aadf5 100644 --- a/app/src/os/services/ship/wallet/wallet.db.ts +++ b/app/src/os/services/ship/wallet/wallet.db.ts @@ -223,6 +223,11 @@ create unique index if not exists path_uindex on wallets (path); `; +export const walletWipeSql = ` +drop table if exists transactions; +drop table if exists wallets; +`; + export const walletDBPreload = WalletDB.preload( new WalletDB({ preload: true, name: 'walletDB' }) ); diff --git a/app/src/renderer/apps/Courier/views/Inbox/Inbox.tsx b/app/src/renderer/apps/Courier/views/Inbox/Inbox.tsx index d7a9434e92..a627e77732 100644 --- a/app/src/renderer/apps/Courier/views/Inbox/Inbox.tsx +++ b/app/src/renderer/apps/Courier/views/Inbox/Inbox.tsx @@ -18,6 +18,7 @@ export const InboxPresenter = ({ isStandaloneChat = false }: Props) => { const { sortedChatList, sortedStandaloneChatList, + setStandaloneChat, inboxLoader, inboxMetadataLoader, setChat, @@ -40,6 +41,7 @@ export const InboxPresenter = ({ isStandaloneChat = false }: Props) => { isChatPinned={isChatPinned} onClickInbox={(path) => { setChat(path); + isStandaloneChat ? setStandaloneChat(path) : setChat(path); setSubroute('chat'); }} onClickNewInbox={() => { diff --git a/app/src/renderer/apps/Courier/views/Inbox/InboxRow.tsx b/app/src/renderer/apps/Courier/views/Inbox/InboxRow.tsx index 9462fe141e..d05ec2bdda 100644 --- a/app/src/renderer/apps/Courier/views/Inbox/InboxRow.tsx +++ b/app/src/renderer/apps/Courier/views/Inbox/InboxRow.tsx @@ -36,7 +36,7 @@ export const InboxRow = ({ return ( diff --git a/app/src/renderer/apps/StandaloneChat/StandaloneChatBody.tsx b/app/src/renderer/apps/StandaloneChat/StandaloneChatBody.tsx index 79ef33b417..6e4731d064 100644 --- a/app/src/renderer/apps/StandaloneChat/StandaloneChatBody.tsx +++ b/app/src/renderer/apps/StandaloneChat/StandaloneChatBody.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; import { observer } from 'mobx-react'; +import styled, { css } from 'styled-components'; +import { useToggle } from '@holium/design-system'; import { Flex, Spinner, Text } from '@holium/design-system/general'; import { useAppState } from 'renderer/stores/app.store'; @@ -18,11 +21,33 @@ import { StandaloneChatPassport } from './StandaloneChatPassport'; import { StandaloneChatPassportPreview } from './StandaloneChatPassportPreview'; import { StandaloneChatRoom } from './StandaloneChatRoom'; +const StandaloneBackgroundImage = styled(motion.img)` + ${(props: { src?: string }) => + props.src && + css` + user-select: none; + position: absolute; + right: 0px; + left: 0px; + z-index: -1; + top: 0px; + bottom: 0px; + width: calc(100%); + height: calc(100vh); + object-fit: cover; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + background-image: url(${props.src}); + `} +`; + export const StandaloneChatBodyPresenter = () => { - const { showTitleBar } = useAppState(); - const { chatStore } = useShipStore(); + const { showTitleBar, theme } = useAppState(); + const { chatStore, settingsStore } = useShipStore(); const [sidebarWidth, setSidebarWidth] = useState(400); + const showBackgroundImage = useToggle(false); const onMouseDownResizeHandle = (e: React.MouseEvent) => { e.preventDefault(); @@ -43,6 +68,18 @@ export const StandaloneChatBodyPresenter = () => { window.addEventListener('mouseup', onMouseUp); }; + useEffect(() => { + if (chatStore.selectedChat?.type === 'space') { + showBackgroundImage.setToggle( + settingsStore.standaloneChatSpaceWallpaperEnabled + ); + } else { + showBackgroundImage.setToggle( + settingsStore.standaloneChatPersonalWallpaperEnabled + ); + } + }, [chatStore.selectedChat]); + useEffect(() => { // Fetch messages for the selected chat. if (chatStore.subroute === 'inbox') { @@ -102,6 +139,21 @@ export const StandaloneChatBodyPresenter = () => { minWidth={360} background="var(--rlm-dock-color)" > + {chatStore.subroute === 'chat' && showBackgroundImage.isOn && ( + + )} {chatStore.subroute === 'chat' && } {chatStore.subroute === 'chat-info' && } {chatStore.subroute === 'new' && } diff --git a/app/src/renderer/apps/StandaloneChat/StandaloneChatPassport.tsx b/app/src/renderer/apps/StandaloneChat/StandaloneChatPassport.tsx index bffd269fcd..e20ecdaf49 100644 --- a/app/src/renderer/apps/StandaloneChat/StandaloneChatPassport.tsx +++ b/app/src/renderer/apps/StandaloneChat/StandaloneChatPassport.tsx @@ -1,10 +1,15 @@ import { observer } from 'mobx-react'; +import { CheckBox } from '@holium/design-system'; import { Flex } from '@holium/design-system/general'; import { useAppState } from 'renderer/stores/app.store'; +import { useShipStore } from 'renderer/stores/ship.store'; import { ChatLogHeader } from '../Courier/components/ChatLogHeader'; +import { SettingControl } from '../System/components/SettingControl'; +import { SettingPane } from '../System/components/SettingPane'; +import { SettingSection } from '../System/components/SettingSection'; import { AccountPassportSection } from '../System/panels/sections/AccountPassportSection'; type Props = { @@ -13,6 +18,7 @@ type Props = { const StandaloneChatPassportPresenter = ({ onBack }: Props) => { const { loggedInAccount } = useAppState(); + const { settingsStore } = useShipStore(); if (!loggedInAccount) return null; @@ -22,7 +28,6 @@ const StandaloneChatPassportPresenter = ({ onBack }: Props) => { width="100%" height="100%" borderTop="1px solid var(--rlm--color)" - background="var(--rlm-base-color)" > { isStandaloneChat onBack={onBack} /> - + - + + + + + + + } + /> + ); }; diff --git a/app/src/renderer/preload.d.ts b/app/src/renderer/preload.d.ts index b3581cb49b..6d673f11e7 100644 --- a/app/src/renderer/preload.d.ts +++ b/app/src/renderer/preload.d.ts @@ -3,6 +3,7 @@ import { MultiplayerPreloadType } from 'main/preload.multiplayer'; import { realmPreload } from 'os/realm.service'; import { authPreload } from 'os/services/auth/auth.service'; import { onboardingPreload } from 'os/services/auth/onboarding.service'; +import { migrationPreload } from 'os/services/migration/migration.service'; import { chatPreload } from 'os/services/ship/chat/chat.service'; import { friendsPreload } from 'os/services/ship/friends.service'; import { lexiconPreload } from 'os/services/ship/lexicon.service'; @@ -25,6 +26,7 @@ declare global { ship: string; shipService: typeof shipPreload; authService: typeof authPreload; + migrationService: typeof migrationPreload; onboardingService: typeof onboardingPreload; chatService: typeof chatPreload; walletService: typeof walletPreload; diff --git a/app/src/renderer/stores/chat.store.ts b/app/src/renderer/stores/chat.store.ts index 19983eb7ea..f9cce66f10 100644 --- a/app/src/renderer/stores/chat.store.ts +++ b/app/src/renderer/stores/chat.store.ts @@ -228,6 +228,10 @@ export const ChatStore = types } self.subroute = subroute; }), + setStandaloneChat(path: string) { + this.setChat(path); + this.updateStandaloneChatTheme(); + }, setChat: flow(function* (path: string) { self.chatLoader.set('loading'); self.selectedChat = tryReference(() => @@ -326,6 +330,23 @@ export const ChatStore = types refreshInbox: flow(function* () { self.inbox = yield ChatIPC.getChatList(); }), + updateStandaloneChatTheme() { + const selectedChat = self.selectedChat; + if (!selectedChat) return; + const spacesStore: SpacesStoreType = getParentOfType( + self, + ShipStore + ).spacesStore; + if (selectedChat.type === 'space') { + // get space theme + const selectedSpace = spacesStore.getSpaceByChatPath(selectedChat.path); + if (!selectedSpace) return; + spacesStore.selectSpace(selectedSpace?.path); + } else { + // personal theme + spacesStore.selectSpace(spacesStore.ourSpace.path); + } + }, reset() { self.subroute = 'inbox'; self.pinnedChats.clear(); diff --git a/app/src/renderer/stores/models/settings.model.ts b/app/src/renderer/stores/models/settings.model.ts index 6bde7860f2..c85cd924b7 100644 --- a/app/src/renderer/stores/models/settings.model.ts +++ b/app/src/renderer/stores/models/settings.model.ts @@ -10,6 +10,8 @@ export const SettingsModel = types isolationModeEnabled: types.boolean, realmCursorEnabled: types.boolean, profileColorForCursorEnabled: types.boolean, + standaloneChatSpaceWallpaperEnabled: types.boolean, + standaloneChatPersonalWallpaperEnabled: types.boolean, }) .actions((self) => ({ init: flow(function* (identity: string) { @@ -21,6 +23,8 @@ export const SettingsModel = types self.isolationModeEnabled = false; self.realmCursorEnabled = true; self.profileColorForCursorEnabled = true; + self.standaloneChatPersonalWallpaperEnabled = false; + self.standaloneChatSpaceWallpaperEnabled = true; return; } else { self.identity = setting.identity; @@ -29,7 +33,12 @@ export const SettingsModel = types self.profileColorForCursorEnabled = Boolean( setting.profileColorForCursorEnabled ); - + self.standaloneChatSpaceWallpaperEnabled = Boolean( + setting.standaloneChatSpaceWallpaperEnabled + ); + self.standaloneChatPersonalWallpaperEnabled = Boolean( + setting.standaloneChatPersonalWallpaperEnabled + ); // Sync Electron isolation mode with settings. if (self.isolationModeEnabled) { window.electron.app.enableIsolationMode(); @@ -54,6 +63,10 @@ export const SettingsModel = types isolationModeEnabled: self.isolationModeEnabled ? 1 : 0, realmCursorEnabled: self.realmCursorEnabled ? 1 : 0, profileColorForCursorEnabled: self.profileColorForCursorEnabled ? 1 : 0, + standaloneChatSpaceWallpaperEnabled: + self.standaloneChatSpaceWallpaperEnabled ? 1 : 0, + standaloneChatPersonalWallpaperEnabled: + self.standaloneChatPersonalWallpaperEnabled ? 1 : 0, }; }, toggleIsolationMode() { @@ -71,6 +84,30 @@ export const SettingsModel = types window.electron.app.disableIsolationMode(); } }, + toggleStandaloneChatSpaceWallpaperEnabled() { + const newStandaloneChatSpaceWallpaperEnabled = + !self.standaloneChatSpaceWallpaperEnabled; + self.standaloneChatSpaceWallpaperEnabled = + newStandaloneChatSpaceWallpaperEnabled; + + SettingsIPC.set({ + ...this._getCurrentSettings(), + standaloneChatSpaceWallpaperEnabled: + newStandaloneChatSpaceWallpaperEnabled ? 1 : 0, + }); + }, + toggleStandaloneChatPersonalWallpaperEnabled() { + const newStandaloneChatPersonalWallpaperEnabled = + !self.standaloneChatPersonalWallpaperEnabled; + self.standaloneChatPersonalWallpaperEnabled = + newStandaloneChatPersonalWallpaperEnabled; + + SettingsIPC.set({ + ...this._getCurrentSettings(), + standaloneChatPersonalWallpaperEnabled: + newStandaloneChatPersonalWallpaperEnabled ? 1 : 0, + }); + }, setRealmCursor(enabled: boolean) { self.realmCursorEnabled = enabled; @@ -100,6 +137,8 @@ export const SettingsModel = types isolationModeEnabled: false, realmCursorEnabled: true, profileColorForCursorEnabled: true, + standaloneChatPersonalWallpaperEnabled: false, + standaloneChatSpaceWallpaperEnabled: true, }); }, })); diff --git a/app/src/renderer/stores/ship.store.ts b/app/src/renderer/stores/ship.store.ts index ac6c5db055..b9f7838e5b 100644 --- a/app/src/renderer/stores/ship.store.ts +++ b/app/src/renderer/stores/ship.store.ts @@ -134,6 +134,8 @@ export const shipStore = ShipStore.create({ isolationModeEnabled: false, realmCursorEnabled: true, profileColorForCursorEnabled: true, + standaloneChatPersonalWallpaperEnabled: false, + standaloneChatSpaceWallpaperEnabled: true, }, loader: { state: 'initial', diff --git a/app/test/README.md b/app/test/README.md deleted file mode 100644 index 29513ed472..0000000000 --- a/app/test/README.md +++ /dev/null @@ -1,12 +0,0 @@ -## Testing - -run `yarn test` with: - -- `./zod/.run --http-port 8080` -- `./bus/.run --http-port 8081` -- `./dev/.run --http-port 8082` - -### Info - -We have to set ELECTRON_RUN_AS_NODE for running tests. The node version of -`better-sqlite-3` must match the version used by `jest`. See [this comment](https://github.com/WiseLibs/better-sqlite3/issues/545#issuecomment-824887942) for more diff --git a/app/test/api/spaces/spaces.test.ts b/app/test/api/spaces/spaces.test.ts deleted file mode 100644 index 77cfdc96ea..0000000000 --- a/app/test/api/spaces/spaces.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; - -import { APIConnection, ConduitSession } from '../../../src/os/services/api'; -import { ShipService } from '../../../src/os/services/ship/ship.service'; -import { SpacesService } from '../../../src/os/services/ship/spaces/spaces.service'; -import { ships } from '../../ships'; - -jest.mock('electron-log', () => { - return { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - }; -}); - -jest.mock('electron', () => { - const originalModule = jest.requireActual('electron'); - - return { - // __esModule: true, - ...originalModule, - ipcMain: { - on: jest.fn().mockReturnThis(), - handle: jest.fn().mockReturnThis(), - }, - BrowserWindow: { - getAllWindows: jest.fn().mockReturnValue([]), - }, - app: { - getPath: jest.fn().mockImplementation(() => { - return `/home/ajlamarc/.holium`; - }), - }, - }; -}); - -jest.mock( - 'ShipService', - () => { - const mockGetCredentials = { - on: jest.fn().mockReturnValue({ - code: ships.zod.code, - url: ships.zod.url, - // cookie: 'cookie', - }), - }; - return { getCredentials: mockGetCredentials }; - }, - { virtual: true } -); - -describe('basic spaces tests', () => { - let service: SpacesService; - let connection: APIConnection; - beforeAll(async () => { - const session: ConduitSession = { - url: ships.zod.url, - ship: ships.zod.name, - code: ships.zod.code, - cookie: 'cookie', - }; - connection = await APIConnection.getInstanceAsync(session); - const shipService = new ShipService(ships.zod.name, 'foo'); - // @ts-ignore - service = new SpacesService(undefined, shipService.shipDB.db); - }); - - test('friends demo', async () => { - console.log(await service.getInitial()); - }); - - afterAll(async () => { - const closed = await connection.closeChannel(); - expect(closed).toBe(true); - }); -}); diff --git a/app/test/ships.ts b/app/test/ships.ts deleted file mode 100644 index 3a85d10848..0000000000 --- a/app/test/ships.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const ships = { - zod: { - name: 'zod', - url: 'http://localhost:8080', - code: 'lidlut-tabwed-pillex-ridrup', - }, - bus: { - name: 'bus', - url: 'http://localhost:8081', - code: 'riddec-bicrym-ridlev-pocsef', - }, - dev: { - name: 'dev', - url: 'http://localhost:8082', - code: 'magsub-micsev-bacmug-moldex', - }, -};