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 { TimelineContext, useTimelineState } from '@/components/timeline/TimelineContext';
import { ColumnWidth } from '@/core/column';
import { useHandleCommand } from '@/hooks/useCommandBus';
import { useTranslation } from '@/i18n/useTranslation';
export type ColumnProps = {
columnIndex: number;
lastColumn: boolean;
width: 'widest' | 'wide' | 'medium' | 'narrow' | null | undefined;
width: ColumnWidth;
header: JSX.Element;
children: JSX.Element;
};

View File

@@ -15,7 +15,7 @@ import UserNameDisplay from '@/components/UserDisplayName';
import LazyLoad from '@/components/utils/LazyLoad';
import usePopup from '@/components/utils/usePopup';
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 { useTranslation } from '@/i18n/useTranslation';
import usePubkey from '@/nostr/usePubkey';
@@ -83,7 +83,7 @@ const ProfileSection = () => {
const { showProfile, showProfileEdit } = useModalState();
return (
<Section title={i18n.t('config.profile.profile')}>
<Section title={i18n.t('config.account.profile')}>
<div class="flex gap-2 py-1">
<button
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
class="rounded border border-primary px-4 py-1 font-bold text-primary"
onClick={() => showProfileEdit()}
>
{i18n.t('config.profile.editProfile')}
{i18n.t('config.account.editProfile')}
</button>
</div>
</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 i18n = useTranslation();
const { config, addRelay, removeRelay } = useConfig();
@@ -639,9 +718,14 @@ const ConfigUI = (props: ConfigProps) => {
const menu = [
{
name: () => i18n.t('config.profile.profile'),
name: () => i18n.t('config.account.profile'),
icon: () => <User />,
render: () => <ProfileSection />,
render: () => (
<>
<ProfileSection />
<BackupSection />
</>
),
},
{
name: () => i18n.t('config.relays.relays'),

View File

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

View File

@@ -1,3 +1,5 @@
import { z } from 'zod';
/**
* Content filter is a text which expresses the filter.
*
@@ -19,6 +21,7 @@
* Case-sensitive
* "Hello World"
*/
export type ContentFilterAnd = { filterType: 'AND'; children: ContentFilter[] };
export type ContentFilterOr = { filterType: 'OR'; children: ContentFilter[] };
export type ContentFilterNot = { filterType: 'NOT'; child: ContentFilter };
@@ -32,6 +35,55 @@ export type ContentFilter =
| ContentFilterTextInclude
| 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 =
(filter: ContentFilter) =>
(content: string): boolean => {

View File

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

View File

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

View File

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