From 9ac081f14a90b158e70f7ac40d6466b4a61e7043 Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Sat, 15 Jun 2024 15:38:21 +0900 Subject: [PATCH] feat: enable to save/restore configuration --- src/components/column/Column.tsx | 3 +- src/components/modal/Config.tsx | 96 +++++++++++++++++++-- src/core/column.ts | 140 +++++++++++++++++-------------- src/core/contentFilter.ts | 52 ++++++++++++ src/core/useConfig.ts | 66 +++++++++------ src/locales/en.ts | 7 +- src/locales/ja.ts | 7 +- 7 files changed, 274 insertions(+), 97 deletions(-) diff --git a/src/components/column/Column.tsx b/src/components/column/Column.tsx index 0d0d77c..296a751 100644 --- a/src/components/column/Column.tsx +++ b/src/components/column/Column.tsx @@ -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; }; diff --git a/src/components/modal/Config.tsx b/src/components/modal/Config.tsx index 102aaf5..371588d 100644 --- a/src/components/modal/Config.tsx +++ b/src/components/modal/Config.tsx @@ -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 ( -
+
); }; +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 = (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 ( +
+
+ + +
+ +
+ ); +}; + 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: () => , - render: () => , + render: () => ( + <> + + + + ), }, { name: () => i18n.t('config.relays.relays'), diff --git a/src/core/column.ts b/src/core/column.ts index 6356219..ce1d450 100644 --- a/src/core/column.ts +++ b/src/core/column.ts @@ -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; + +export const BaseColumnSchema = z.object({ + id: z.string(), + name: z.string().optional(), + width: ColumnWidthSchema, + contentFilter: z.optional(ContentFilterSchema), +}); + +export type BaseColumn = z.infer; /** 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; /** 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; /** 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; /** 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; -/** 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; /** 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; /** 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; /** 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; -/** 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; type CreateParams = Omit & Partial; diff --git a/src/core/contentFilter.ts b/src/core/contentFilter.ts index e0660dc..5f741ee 100644 --- a/src/core/contentFilter.ts +++ b/src/core/contentFilter.ts @@ -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 = z.union([ + ContentFilterAndSchema, + ContentFilterOrSchema, + ContentFilterNotSchema, + ContentFilterTextIncludeSchema, + ContentFilterRegexSchema, +]); + export const applyContentFilter = (filter: ContentFilter) => (content: string): boolean => { diff --git a/src/core/useConfig.ts b/src/core/useConfig.ts index 9d553d0..61efbdf 100644 --- a/src/core/useConfig.ts +++ b/src/core/useConfig.ts @@ -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; -export type Config = { - relayUrls: string[]; - columns: ColumnType[]; - customEmojis: Record; - 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; + +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; type UseConfig = { config: Accessor; diff --git a/src/locales/en.ts b/src/locales/en.ts index 1e66971..a3ea5bf 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index bc36f5c..41e3365 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -155,10 +155,15 @@ export default { confirmImport: 'インポートしますか?(現在の設定は上書きされます)', copyToClipboard: 'クリップボードに設定をコピー', importFromClipboard: '設定をクリップボードから読み込む', - profile: { + account: { profile: 'プロフィール', openProfile: '開く', editProfile: '編集', + backupConfig: '設定のバックアップ', + save: '保存', + restore: '復元', + restored: '復元しました', + failedToRestore: '復元に失敗しました', }, relays: { relays: 'リレー',