Refactor app metadata

This commit is contained in:
Hakan Shehu
2025-01-29 11:05:45 +01:00
parent ed42dcfe8b
commit 62ca038252
16 changed files with 282 additions and 45 deletions

View File

@@ -5,7 +5,6 @@ import { createDebugger } from '@colanode/core';
import { app, BrowserWindow, ipcMain, protocol, shell } from 'electron';
import path from 'path';
import { WindowSize } from '@/shared/types/metadata';
import { mediator } from '@/main/mediator';
import { getAppIconPath } from '@/main/lib/utils';
import { CommandInput, CommandMap } from '@/shared/commands';
@@ -44,7 +43,7 @@ const createWindow = async () => {
await appService.migrate();
// Create the browser window.
let windowSize = await appService.metadata.get<WindowSize>('window_size');
let windowSize = (await appService.metadata.get('window_size'))?.value;
const mainWindow = new BrowserWindow({
width: windowSize?.width ?? 1200,
height: windowSize?.height ?? 800,

View File

@@ -38,20 +38,20 @@ interface DeletedTokenTable {
created_at: ColumnType<string, string, string>;
}
interface MetadataTable {
interface AppMetadataTable {
key: ColumnType<string, string, never>;
value: ColumnType<string, string, string>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
}
export type SelectMetadata = Selectable<MetadataTable>;
export type CreateMetadata = Insertable<MetadataTable>;
export type UpdateMetadata = Updateable<MetadataTable>;
export type SelectAppMetadata = Selectable<AppMetadataTable>;
export type CreateAppMetadata = Insertable<AppMetadataTable>;
export type UpdateAppMetadata = Updateable<AppMetadataTable>;
export interface AppDatabaseSchema {
servers: ServerTable;
accounts: AccountTable;
deleted_tokens: DeletedTokenTable;
metadata: MetadataTable;
metadata: AppMetadataTable;
}

View File

@@ -6,7 +6,11 @@ import {
} from '@colanode/core';
import { encodeState } from '@colanode/crdt';
import { SelectAccount, SelectServer } from '@/main/databases/app';
import {
SelectAccount,
SelectAppMetadata,
SelectServer,
} from '@/main/databases/app';
import { SelectEmoji } from '@/main/databases/emojis';
import { SelectIcon } from '@/main/databases/icons';
import { SelectWorkspace } from '@/main/databases/account';
@@ -41,6 +45,7 @@ import {
import { EntryInteraction } from '@/shared/types/entries';
import { Emoji } from '@/shared/types/emojis';
import { Icon } from '@/shared/types/icons';
import { AppMetadata, AppMetadataKey } from '@/shared/types/apps';
export const mapUser = (row: SelectUser): User => {
return {
@@ -291,6 +296,14 @@ export const mapIcon = (row: SelectIcon): Icon => {
};
};
export const mapAppMetadata = (row: SelectAppMetadata): AppMetadata => {
return {
key: row.key as AppMetadataKey,
value: JSON.parse(row.value),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
};
export const mapWorkspaceMetadata = (
row: SelectWorkspaceMetadata
): WorkspaceMetadata => {

View File

@@ -0,0 +1,66 @@
import { ChangeCheckResult, QueryHandler } from '@/main/lib/types';
import { mapAppMetadata } from '@/main/lib/mappers';
import { AppMetadataListQueryInput } from '@/shared/queries/apps/app-metadata-list';
import { Event } from '@/shared/types/events';
import { AppMetadata } from '@/shared/types/apps';
import { SelectAppMetadata } from '@/main/databases/app/schema';
import { appService } from '@/main/services/app-service';
export class AppMetadataListQueryHandler
implements QueryHandler<AppMetadataListQueryInput>
{
public async handleQuery(
_: AppMetadataListQueryInput
): Promise<AppMetadata[]> {
const rows = await this.getAppMetadata();
if (!rows) {
return [];
}
return rows.map(mapAppMetadata);
}
public async checkForChanges(
event: Event,
_: AppMetadataListQueryInput,
output: AppMetadata[]
): Promise<ChangeCheckResult<AppMetadataListQueryInput>> {
if (event.type === 'app_metadata_updated') {
const newOutput = output.map((metadata) => {
if (metadata.key === event.metadata.key) {
return event.metadata;
}
return metadata;
});
return {
hasChanges: true,
result: newOutput,
};
}
if (event.type === 'app_metadata_deleted') {
const newOutput = output.filter(
(metadata) => metadata.key !== event.metadata.key
);
return {
hasChanges: true,
result: newOutput,
};
}
return {
hasChanges: false,
};
}
private async getAppMetadata(): Promise<SelectAppMetadata[] | undefined> {
const rows = await appService.database
.selectFrom('metadata')
.selectAll()
.execute();
return rows;
}
}

View File

@@ -1,3 +1,4 @@
import { AppMetadataListQueryHandler } from '@/main/queries/apps/app-metadata-list';
import { AccountGetQueryHandler } from '@/main/queries/accounts/account-get';
import { AccountListQueryHandler } from '@/main/queries/accounts/accounts-list';
import { EmojiGetQueryHandler } from '@/main/queries/emojis/emoji-get';
@@ -41,6 +42,7 @@ type QueryHandlerMap = {
};
export const queryHandlerMap: QueryHandlerMap = {
app_metadata_list: new AppMetadataListQueryHandler(),
account_list: new AccountListQueryHandler(),
message_list: new MessageListQueryHandler(),
message_reaction_list: new MessageReactionsListQueryHandler(),

View File

@@ -18,6 +18,7 @@ import { EventLoop } from '@/main/lib/event-loop';
import { parseApiError } from '@/shared/lib/axios';
import { NotificationService } from '@/main/services/notification-service';
import { eventBus } from '@/shared/lib/event-bus';
import { AppPlatform } from '@/shared/types/apps';
export class AppService {
private readonly debug = createDebugger('desktop:service:app');
@@ -74,6 +75,9 @@ export class AppService {
});
await migrator.migrateToLatest();
await this.metadata.set('version', this.version);
await this.metadata.set('platform', process.platform as AppPlatform);
}
public getAccount(id: string): AccountService | null {

View File

@@ -1,6 +1,13 @@
import { createDebugger } from '@colanode/core';
import { AppService } from '@/main/services/app-service';
import {
AppMetadata,
AppMetadataMap,
AppMetadataKey,
} from '@/shared/types/apps';
import { mapAppMetadata } from '@/main/lib/mappers';
import { eventBus } from '@/shared/lib/event-bus';
export class MetadataService {
private readonly debug = createDebugger('desktop:service:metadata');
@@ -10,7 +17,18 @@ export class MetadataService {
this.app = app;
}
public async get<T>(key: string): Promise<T | null> {
public async getAll(): Promise<AppMetadata[]> {
const metadata = await this.app.database
.selectFrom('metadata')
.selectAll()
.execute();
return metadata.map(mapAppMetadata);
}
public async get<K extends AppMetadataKey>(
key: K
): Promise<AppMetadataMap[K] | null> {
const metadata = await this.app.database
.selectFrom('metadata')
.selectAll()
@@ -21,14 +39,18 @@ export class MetadataService {
return null;
}
return JSON.parse(metadata.value) as T;
return mapAppMetadata(metadata) as AppMetadataMap[K];
}
public async set<T>(key: string, value: T) {
public async set<K extends AppMetadataKey>(
key: K,
value: AppMetadataMap[K]['value']
) {
this.debug(`Setting metadata key ${key} to value ${value}`);
await this.app.database
const createdMetadata = await this.app.database
.insertInto('metadata')
.returningAll()
.values({
key,
value: JSON.stringify(value),
@@ -40,15 +62,34 @@ export class MetadataService {
updated_at: new Date().toISOString(),
})
)
.execute();
.executeTakeFirst();
if (!createdMetadata) {
return;
}
eventBus.publish({
type: 'app_metadata_updated',
metadata: mapAppMetadata(createdMetadata),
});
}
public async delete(key: string) {
this.debug(`Deleting metadata key ${key}`);
await this.app.database
const deletedMetadata = await this.app.database
.deleteFrom('metadata')
.where('key', '=', key)
.execute();
.returningAll()
.executeTakeFirst();
if (!deletedMetadata) {
return;
}
eventBus.publish({
type: 'app_metadata_deleted',
metadata: mapAppMetadata(deletedMetadata),
});
}
}

View File

@@ -1,6 +1,6 @@
import { Spinner } from '@/renderer/components/ui/spinner';
export const RootLoader = () => {
export const AppLoader = () => {
return (
<div className="min-w-screen flex h-full min-h-screen w-full items-center justify-center">
<div className="flex flex-col items-center gap-8 text-center">

View File

@@ -1,11 +1,45 @@
import { Outlet } from 'react-router-dom';
import React from 'react';
import { AppContext } from './contexts/app';
import { DelayedComponent } from '@/renderer/components/ui/delayed-component';
import { AppLoader } from '@/renderer/app-loader';
import { useQuery } from '@/renderer/hooks/use-query';
import { RadarProvider } from '@/renderer/radar-provider';
export const App = () => {
const [initialized, setInitialized] = React.useState(false);
const { data, isPending } = useQuery({
type: 'app_metadata_list',
});
React.useEffect(() => {
window.colanode.init().then(() => {
setInitialized(true);
});
}, []);
if (!initialized || isPending) {
return (
<DelayedComponent>
<AppLoader />
</DelayedComponent>
);
}
return (
<AppContext.Provider
value={{
getMetadata: (key: string) => {
return data?.find((metadata) => metadata.key === key)?.value;
},
}}
>
<RadarProvider>
<Outlet />
</RadarProvider>
</AppContext.Provider>
);
};

View File

@@ -4,6 +4,7 @@ import { SidebarMenuIcon } from '@/renderer/components/layouts/sidebars/sidebar-
import { SidebarMenuHeader } from '@/renderer/components/layouts/sidebars/sidebar-menu-header';
import { SidebarMenuFooter } from '@/renderer/components/layouts/sidebars/sidebar-menu-footer';
import { SidebarMenuType } from '@/shared/types/workspaces';
import { useApp } from '@/renderer/contexts/app';
interface SidebarMenuProps {
value: SidebarMenuType;
@@ -11,13 +12,22 @@ interface SidebarMenuProps {
}
export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
const app = useApp();
const platform = app.getMetadata('platform');
const windowSize = app.getMetadata('window_size');
const showMacOsPlaceholder = platform === 'darwin' && !windowSize?.fullscreen;
return (
<div className="flex flex-col h-full w-[65px] min-w-[65px] items-center bg-slate-100">
{showMacOsPlaceholder ? (
<div className="w-full h-8 flex gap-[8px] px-[6px] py-[7px]">
<div className="w-3 h-3 bg-gray-400 rounded-full"></div>
<div className="w-3 h-3 bg-gray-400 rounded-full"></div>
<div className="w-3 h-3 bg-gray-400 rounded-full"></div>
</div>
) : (
<div className="w-full h-4" />
)}
<SidebarMenuHeader />
<div className="flex flex-col gap-1 mt-2 w-full p-2 items-center flex-grow">
<SidebarMenuIcon

View File

@@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';
import { AppMetadataKey, AppMetadataMap } from '@/shared/types/apps';
interface AppContext {
getMetadata: <K extends AppMetadataKey>(
key: K
) => AppMetadataMap[K]['value'] | undefined;
}
export const AppContext = createContext<AppContext>({} as AppContext);
export const useApp = () => useContext(AppContext);

View File

@@ -11,14 +11,12 @@ import { App } from '@/renderer/app';
import { Account } from '@/renderer/components/accounts/account';
import { AccountRedirect } from '@/renderer/components/accounts/account-redirect';
import { Login } from '@/renderer/components/accounts/login';
import { DelayedComponent } from '@/renderer/components/ui/delayed-component';
import { Toaster } from '@/renderer/components/ui/toaster';
import { TooltipProvider } from '@/renderer/components/ui/tooltip';
import { Workspace } from '@/renderer/components/workspaces/workspace';
import { WorkspaceCreate } from '@/renderer/components/workspaces/workspace-create';
import { WorkspaceRedirect } from '@/renderer/components/workspaces/workspace-redirect';
import { useEventBus } from '@/renderer/hooks/use-event-bus';
import { RootLoader } from '@/renderer/root-loader';
import { Event } from '@/shared/types/events';
const router = createHashRouter([
@@ -56,7 +54,7 @@ const router = createHashRouter([
},
]);
const queryClient = new QueryClient({
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'always',
@@ -69,7 +67,6 @@ const queryClient = new QueryClient({
const Root = () => {
const eventBus = useEventBus();
const [initialized, setInitialized] = React.useState(false);
React.useEffect(() => {
const id = eventBus.subscribe((event: Event) => {
@@ -96,23 +93,11 @@ const Root = () => {
}
});
window.colanode.init().then(() => {
setInitialized(true);
});
return () => {
eventBus.unsubscribe(id);
};
}, []);
if (!initialized) {
return (
<DelayedComponent>
<RootLoader />
</DelayedComponent>
);
}
return (
<QueryClientProvider client={queryClient}>
<DndProvider backend={HTML5Backend}>

View File

@@ -0,0 +1,14 @@
import { AppMetadata } from '@/shared/types/apps';
export type AppMetadataListQueryInput = {
type: 'app_metadata_list';
};
declare module '@/shared/queries' {
interface QueryMap {
app_metadata_list: {
input: AppMetadataListQueryInput;
output: AppMetadata[];
};
}
}

View File

@@ -0,0 +1,48 @@
export type AppPlatform =
| 'aix'
| 'darwin'
| 'freebsd'
| 'linux'
| 'openbsd'
| 'sunos'
| 'win32';
export type WindowSize = {
width: number;
height: number;
fullscreen: boolean;
};
export type AppPlatformMetadata = {
key: 'platform';
value: AppPlatform;
createdAt: string;
updatedAt: string | null;
};
export type AppVersionMetadata = {
key: 'version';
value: string;
createdAt: string;
updatedAt: string | null;
};
export type AppWindowSizeMetadata = {
key: 'window_size';
value: WindowSize;
createdAt: string;
updatedAt: string | null;
};
export type AppMetadata =
| AppPlatformMetadata
| AppVersionMetadata
| AppWindowSizeMetadata;
export type AppMetadataKey = AppMetadata['key'];
export type AppMetadataMap = {
platform: AppPlatformMetadata;
version: AppVersionMetadata;
window_size: AppWindowSizeMetadata;
};

View File

@@ -1,5 +1,6 @@
import { Entry, Message } from '@colanode/core';
import { AppMetadata } from '@/shared/types/apps';
import { EntryInteraction } from '@/shared/types/entries';
import {
MessageInteraction,
@@ -238,6 +239,16 @@ export type AccountConnectionMessageEvent = {
message: Message;
};
export type AppMetadataUpdatedEvent = {
type: 'app_metadata_updated';
metadata: AppMetadata;
};
export type AppMetadataDeletedEvent = {
type: 'app_metadata_deleted';
metadata: AppMetadata;
};
export type WorkspaceMetadataUpdatedEvent = {
type: 'workspace_metadata_updated';
accountId: string;
@@ -289,5 +300,7 @@ export type Event =
| AccountConnectionOpenedEvent
| AccountConnectionClosedEvent
| AccountConnectionMessageEvent
| AppMetadataUpdatedEvent
| AppMetadataDeletedEvent
| WorkspaceMetadataUpdatedEvent
| WorkspaceMetadataDeletedEvent;

View File

@@ -1,5 +0,0 @@
export type WindowSize = {
width: number;
height: number;
fullscreen: boolean;
};