feat: enable to save/restore configuration

This commit is contained in:
Shusui MOYATANI
2024-06-15 15:38:21 +09:00
parent f9ba947eb4
commit 9ac081f14a
7 changed files with 274 additions and 97 deletions

View File

@@ -4,13 +4,14 @@ import ArrowLeft from 'heroicons/24/outline/arrow-left.svg';
import TimelineContentDisplay from '@/components/timeline/TimelineContentDisplay'; import TimelineContentDisplay from '@/components/timeline/TimelineContentDisplay';
import { TimelineContext, useTimelineState } from '@/components/timeline/TimelineContext'; import { TimelineContext, useTimelineState } from '@/components/timeline/TimelineContext';
import { ColumnWidth } from '@/core/column';
import { useHandleCommand } from '@/hooks/useCommandBus'; import { useHandleCommand } from '@/hooks/useCommandBus';
import { useTranslation } from '@/i18n/useTranslation'; import { useTranslation } from '@/i18n/useTranslation';
export type ColumnProps = { export type ColumnProps = {
columnIndex: number; columnIndex: number;
lastColumn: boolean; lastColumn: boolean;
width: 'widest' | 'wide' | 'medium' | 'narrow' | null | undefined; width: ColumnWidth;
header: JSX.Element; header: JSX.Element;
children: JSX.Element; children: JSX.Element;
}; };

View File

@@ -15,7 +15,7 @@ import UserNameDisplay from '@/components/UserDisplayName';
import LazyLoad from '@/components/utils/LazyLoad'; import LazyLoad from '@/components/utils/LazyLoad';
import usePopup from '@/components/utils/usePopup'; import usePopup from '@/components/utils/usePopup';
import { colorThemes } from '@/core/colorThemes'; import { colorThemes } from '@/core/colorThemes';
import useConfig, { type Config } from '@/core/useConfig'; import useConfig, { ConfigSchema, type Config } from '@/core/useConfig';
import useModalState from '@/hooks/useModalState'; import useModalState from '@/hooks/useModalState';
import { useTranslation } from '@/i18n/useTranslation'; import { useTranslation } from '@/i18n/useTranslation';
import usePubkey from '@/nostr/usePubkey'; import usePubkey from '@/nostr/usePubkey';
@@ -83,7 +83,7 @@ const ProfileSection = () => {
const { showProfile, showProfileEdit } = useModalState(); const { showProfile, showProfileEdit } = useModalState();
return ( return (
<Section title={i18n.t('config.profile.profile')}> <Section title={i18n.t('config.account.profile')}>
<div class="flex gap-2 py-1"> <div class="flex gap-2 py-1">
<button <button
class="rounded border border-primary px-4 py-1 font-bold text-primary" class="rounded border border-primary px-4 py-1 font-bold text-primary"
@@ -93,19 +93,98 @@ const ProfileSection = () => {
}) })
} }
> >
{i18n.t('config.profile.openProfile')} {i18n.t('config.account.openProfile')}
</button> </button>
<button <button
class="rounded border border-primary px-4 py-1 font-bold text-primary" class="rounded border border-primary px-4 py-1 font-bold text-primary"
onClick={() => showProfileEdit()} onClick={() => showProfileEdit()}
> >
{i18n.t('config.profile.editProfile')} {i18n.t('config.account.editProfile')}
</button> </button>
</div> </div>
</Section> </Section>
); );
}; };
const BackupSection = () => {
let fileInputRef: HTMLInputElement | undefined;
const i18n = useTranslation();
const config = useConfig();
const handleSave = () => {
const json = JSON.stringify(config.config(), null, 2);
const blob = new Blob([json], { type: 'application/json' });
const dataUrl = URL.createObjectURL(blob);
const datetime = new Date().toISOString();
const link = document.createElement('a');
link.href = dataUrl;
link.download = `rabbit-${datetime}.json`;
link.click();
};
const handleRestore = () => {
if (fileInputRef == null) return;
fileInputRef.click();
};
const restore = async (file: File) => {
try {
const json = await file.text();
const validated = ConfigSchema.parse(JSON.parse(json));
config.setConfig(validated);
window.alert(i18n.t('config.account.restored'));
window.location.reload();
} catch (e) {
if (e instanceof Error) {
window.alert(`${i18n.t('config.account.failedToRestore')}: ${e.message}`);
} else {
window.alert(i18n.t('config.account.failedToRestore'));
}
}
};
const handleChangeFile: JSX.EventHandler<HTMLInputElement, Event> = (ev) => {
ev.preventDefault();
const files = [...(ev.currentTarget.files ?? [])];
if (files.length !== 1) return;
const file = files[0];
restore(file).catch((err) => console.log(err));
};
return (
<Section title={i18n.t('config.account.backupConfig')}>
<div class="flex gap-2 py-1">
<button
class="rounded border border-primary px-4 py-1 font-bold text-primary"
onClick={handleSave}
>
{i18n.t('config.account.save')}
</button>
<button
class="rounded border border-primary px-4 py-1 font-bold text-primary"
onClick={handleRestore}
>
{i18n.t('config.account.restore')}
</button>
</div>
<input
ref={fileInputRef}
type="file"
hidden
multiple={false}
name="config"
accept="application/json"
onChange={handleChangeFile}
/>
</Section>
);
};
const RelayConfig = () => { const RelayConfig = () => {
const i18n = useTranslation(); const i18n = useTranslation();
const { config, addRelay, removeRelay } = useConfig(); const { config, addRelay, removeRelay } = useConfig();
@@ -639,9 +718,14 @@ const ConfigUI = (props: ConfigProps) => {
const menu = [ const menu = [
{ {
name: () => i18n.t('config.profile.profile'), name: () => i18n.t('config.account.profile'),
icon: () => <User />, icon: () => <User />,
render: () => <ProfileSection />, render: () => (
<>
<ProfileSection />
<BackupSection />
</>
),
}, },
{ {
name: () => i18n.t('config.relays.relays'), name: () => i18n.t('config.relays.relays'),

View File

@@ -1,8 +1,6 @@
// import { z } from 'zod'; import { z } from 'zod';
import { type Filter } from 'nostr-tools/filter';
import { type ColumnProps } from '@/components/column/Column'; import { ContentFilterSchema } from '@/core/contentFilter';
import { ContentFilter } from '@/core/contentFilter';
import { relaysForJapaneseTL } from '@/core/relayUrls'; import { relaysForJapaneseTL } from '@/core/relayUrls';
import generateId from '@/utils/generateId'; import generateId from '@/utils/generateId';
@@ -41,80 +39,100 @@ export type NotificationFilterOptions = {
// */ // */
// export const buildFilter = (options: BuildOptions) => {}; // export const buildFilter = (options: BuildOptions) => {};
export type BaseColumn = { export const ColumnWidthSchema = z.union([
id: string; z.literal('widest'),
name?: string; z.literal('wide'),
width: ColumnProps['width']; z.literal('medium'),
contentFilter?: ContentFilter; z.literal('narrow'),
}; ]);
export type ColumnWidth = z.infer<typeof ColumnWidthSchema>;
export const BaseColumnSchema = z.object({
id: z.string(),
name: z.string().optional(),
width: ColumnWidthSchema,
contentFilter: z.optional(ContentFilterSchema),
});
export type BaseColumn = z.infer<typeof BaseColumnSchema>;
/** A column which shows posts by following users */ /** A column which shows posts by following users */
export type FollowingColumnType = BaseColumn & { export const FollowingColumnSchema = BaseColumnSchema.extend({
columnType: 'Following'; columnType: z.literal('Following'),
pubkey: string; pubkey: z.string(),
}; });
export type FollowingColumnType = z.infer<typeof FollowingColumnSchema>;
/** A column which shows replies, reactions, reposts to the specific user */ /** A column which shows replies, reactions, reposts to the specific user */
export type NotificationColumnType = BaseColumn & { export const NotificationColumnSchema = BaseColumnSchema.extend({
columnType: 'Notification'; columnType: z.literal('Notification'),
// notificationTypes: NotificationType[]; pubkey: z.string(),
pubkey: string; // notificationTypes: z.array(NotificationTypeSchema),
}; });
export type NotificationColumnType = z.infer<typeof NotificationColumnSchema>;
/** A column which shows posts from the specific user */ /** A column which shows posts from the specific user */
export type PostsColumnType = BaseColumn & { export const PostsColumnSchema = BaseColumnSchema.extend({
columnType: 'Posts'; columnType: z.literal('Posts'),
pubkey: string; pubkey: z.string(),
}; });
export type PostsColumnType = z.infer<typeof PostsColumnSchema>;
/** A column which shows reactions published by the specific user */ /** A column which shows reactions published by the specific user */
export type ReactionsColumnType = BaseColumn & { export const ReactionsColumnSchema = BaseColumnSchema.extend({
columnType: 'Reactions'; columnType: z.literal('Reactions'),
pubkey: string; pubkey: z.string(),
}; });
export type ReactionsColumnType = z.infer<typeof ReactionsColumnSchema>;
/** A column which shows reactions published by the specific user */ /** A column which shows posts published on the specific channel */
export type ChannelColumnType = BaseColumn & { export const ChannelColumnSchema = BaseColumnSchema.extend({
columnType: 'Channel'; columnType: z.literal('Channel'),
rootEventId: string; rootEventId: z.string(),
}; });
export type ChannelColumnType = z.infer<typeof ChannelColumnSchema>;
/** A column which shows text notes and reposts posted to the specific relays */ /** A column which shows text notes and reposts posted to the specific relays */
export type RelaysColumnType = BaseColumn & { export const RelaysColumnSchema = BaseColumnSchema.extend({
columnType: 'Relays'; columnType: z.literal('Relays'),
relayUrls: string[]; relayUrls: z.array(z.string()),
}; });
export type RelaysColumnType = z.infer<typeof RelaysColumnSchema>;
/** A column which search text notes from relays which support NIP-50 */ /** A column which search text notes from relays which support NIP-50 */
export type SearchColumnType = BaseColumn & { export const SearchColumnSchema = BaseColumnSchema.extend({
columnType: 'Search'; columnType: z.literal('Search'),
query: string; query: z.string(),
}; });
export type SearchColumnType = z.infer<typeof SearchColumnSchema>;
/** A column which shows events in the bookmark */ /** A column which shows events in the bookmark */
export type BookmarkColumnType = BaseColumn & { export const BookmarkColumnSchema = BaseColumnSchema.extend({
columnType: 'Bookmark'; columnType: z.literal('Bookmark'),
pubkey: string; pubkey: z.string(),
identifier: string; identifier: z.string(),
}; });
export type BookmarkColumnType = z.infer<typeof BookmarkColumnSchema>;
/** A column which shows text notes and reposts posted to the specific relays */ // /** A column which shows text notes and reposts posted to the specific relays */
export type CustomFilterColumnType = BaseColumn & { // const CustomFilterColumnSchema = BaseColumnSchema.extend({
columnType: 'CustomFilter'; // columnType: z.literal('CustomFilter'),
filters: Filter[]; // filters: z.array(z.any()), // Replace `z.any()` with the appropriate Zod schema if `Filter` is known
}; // });
export type ColumnType = export const ColumnTypeSchema = z.union([
| FollowingColumnType FollowingColumnSchema,
| NotificationColumnType NotificationColumnSchema,
| PostsColumnType PostsColumnSchema,
| ReactionsColumnType ReactionsColumnSchema,
| ChannelColumnType ChannelColumnSchema,
| RelaysColumnType RelaysColumnSchema,
| SearchColumnType SearchColumnSchema,
| BookmarkColumnType; BookmarkColumnSchema,
/* WIP: */ ]);
/* | CustomFilterColumnType */
export type ColumnType = z.infer<typeof ColumnTypeSchema>;
type CreateParams<T extends BaseColumn> = Omit<T, keyof BaseColumn | 'columnType'> & type CreateParams<T extends BaseColumn> = Omit<T, keyof BaseColumn | 'columnType'> &
Partial<BaseColumn>; Partial<BaseColumn>;

View File

@@ -1,3 +1,5 @@
import { z } from 'zod';
/** /**
* Content filter is a text which expresses the filter. * Content filter is a text which expresses the filter.
* *
@@ -19,6 +21,7 @@
* Case-sensitive * Case-sensitive
* "Hello World" * "Hello World"
*/ */
export type ContentFilterAnd = { filterType: 'AND'; children: ContentFilter[] }; export type ContentFilterAnd = { filterType: 'AND'; children: ContentFilter[] };
export type ContentFilterOr = { filterType: 'OR'; children: ContentFilter[] }; export type ContentFilterOr = { filterType: 'OR'; children: ContentFilter[] };
export type ContentFilterNot = { filterType: 'NOT'; child: ContentFilter }; export type ContentFilterNot = { filterType: 'NOT'; child: ContentFilter };
@@ -32,6 +35,55 @@ export type ContentFilter =
| ContentFilterTextInclude | ContentFilterTextInclude
| ContentFilterRegex; | ContentFilterRegex;
export const ContentFilterAndSchema = z.object({
filterType: z.literal('AND'),
// eslint-disable-next-line @typescript-eslint/no-use-before-define
children: z.array(z.lazy(() => ContentFilterSchema)),
});
export const ContentFilterOrSchema = z.object({
filterType: z.literal('OR'),
// eslint-disable-next-line @typescript-eslint/no-use-before-define
children: z.array(z.lazy(() => ContentFilterSchema)),
});
export const ContentFilterNotSchema = z.object({
filterType: z.literal('NOT'),
// eslint-disable-next-line @typescript-eslint/no-use-before-define
child: z.lazy(() => ContentFilterSchema),
});
export const ContentFilterTextIncludeSchema = z.object({
filterType: z.literal('Text'),
text: z.string(),
});
export const ContentFilterRegexSchema = z
.object({
filterType: z.literal('Regex'),
regex: z.string(),
flag: z.string(),
})
.refine(({ regex, flag }) => {
try {
/* eslint-disable-next-line no-new */
new RegExp(regex, flag);
return true;
} catch {
return false;
}
});
// 再帰的型定義を用いる場合、Zodでは型情報を提供しなければならない。
// https://github.com/colinhacks/zod#recursive-types
export const ContentFilterSchema: z.ZodSchema<ContentFilter> = z.union([
ContentFilterAndSchema,
ContentFilterOrSchema,
ContentFilterNotSchema,
ContentFilterTextIncludeSchema,
ContentFilterRegexSchema,
]);
export const applyContentFilter = export const applyContentFilter =
(filter: ContentFilter) => (filter: ContentFilter) =>
(content: string): boolean => { (content: string): boolean => {

View File

@@ -4,10 +4,12 @@ import sortBy from 'lodash/sortBy';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import * as Kind from 'nostr-tools/kinds'; import * as Kind from 'nostr-tools/kinds';
import { type Event as NostrEvent } from 'nostr-tools/pure'; import { type Event as NostrEvent } from 'nostr-tools/pure';
import { z } from 'zod';
import { colorThemes, type ColorTheme } from '@/core/colorThemes'; import { colorThemes, type ColorTheme } from '@/core/colorThemes';
import { import {
ColumnType, ColumnType,
ColumnTypeSchema,
createFollowingColumn, createFollowingColumn,
createJapanRelaysColumn, createJapanRelaysColumn,
createNotificationColumn, createNotificationColumn,
@@ -23,35 +25,45 @@ import { useTranslation } from '@/i18n/useTranslation';
import { genericEvent } from '@/nostr/event'; import { genericEvent } from '@/nostr/event';
import { asCaseInsensitive, wordsRegex } from '@/utils/regex'; import { asCaseInsensitive, wordsRegex } from '@/utils/regex';
export type CustomEmojiConfig = { const CustomEmojiConfigSchema = z.object({
shortcode: string; shortcode: z.string(),
url: string; url: z.string(),
}; });
export type ColorThemeConfig = { export type CustomEmojiConfig = z.infer<typeof CustomEmojiConfigSchema>;
type: 'specific';
id: string;
};
export type Config = { const ColorThemeConfigSchema = z.object({
relayUrls: string[]; type: z.literal('specific'),
columns: ColumnType[]; id: z.string(),
customEmojis: Record<string, CustomEmojiConfig>; });
colorTheme: ColorThemeConfig;
dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; export type ColorThemeConfig = z.infer<typeof ColorThemeConfigSchema>;
keepOpenPostForm: boolean;
useEmojiReaction: boolean; export const ConfigSchema = z.object({
showEmojiReaction: boolean; relayUrls: z.array(z.string()),
showMedia: boolean; // TODO 'always' | 'only-followings' | 'never' columns: z.array(ColumnTypeSchema),
embedding: { customEmojis: z.record(CustomEmojiConfigSchema),
twitter: boolean; colorTheme: ColorThemeConfigSchema,
youtube: boolean; dateFormat: z.union([
ogp: boolean; z.literal('relative'),
}; z.literal('absolute-long'),
hideCount: boolean; z.literal('absolute-short'),
mutedPubkeys: string[]; ]),
mutedKeywords: string[]; keepOpenPostForm: z.boolean(),
}; useEmojiReaction: z.boolean(),
showEmojiReaction: z.boolean(),
showMedia: z.boolean(), // TODO 'always' | 'only-followings' | 'never'に変更
embedding: z.object({
twitter: z.boolean(),
youtube: z.boolean(),
ogp: z.boolean(),
}),
hideCount: z.boolean(),
mutedPubkeys: z.array(z.string()),
mutedKeywords: z.array(z.string()),
});
export type Config = z.infer<typeof ConfigSchema>;
type UseConfig = { type UseConfig = {
config: Accessor<Config>; config: Accessor<Config>;

View File

@@ -159,10 +159,15 @@ export default {
confirmImport: 'Import? (The config will be overwritten)', confirmImport: 'Import? (The config will be overwritten)',
copyToClipboard: 'Copy to clipboard', copyToClipboard: 'Copy to clipboard',
importFromClipboard: 'Import from clipboard', importFromClipboard: 'Import from clipboard',
profile: { account: {
profile: 'Profile', profile: 'Profile',
openProfile: 'Open', openProfile: 'Open',
editProfile: 'Edit', editProfile: 'Edit',
backupConfig: 'Backup configuration',
save: 'Save',
restore: 'Restore',
restored: 'Successfully restored.',
failedToRestore: 'Failed to restore',
}, },
relays: { relays: {
relays: 'Relays', relays: 'Relays',

View File

@@ -155,10 +155,15 @@ export default {
confirmImport: 'インポートしますか?(現在の設定は上書きされます)', confirmImport: 'インポートしますか?(現在の設定は上書きされます)',
copyToClipboard: 'クリップボードに設定をコピー', copyToClipboard: 'クリップボードに設定をコピー',
importFromClipboard: '設定をクリップボードから読み込む', importFromClipboard: '設定をクリップボードから読み込む',
profile: { account: {
profile: 'プロフィール', profile: 'プロフィール',
openProfile: '開く', openProfile: '開く',
editProfile: '編集', editProfile: '編集',
backupConfig: '設定のバックアップ',
save: '保存',
restore: '復元',
restored: '復元しました',
failedToRestore: '復元に失敗しました',
}, },
relays: { relays: {
relays: 'リレー', relays: 'リレー',