mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
feat: enable to save/restore configuration
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -155,10 +155,15 @@ export default {
|
||||
confirmImport: 'インポートしますか?(現在の設定は上書きされます)',
|
||||
copyToClipboard: 'クリップボードに設定をコピー',
|
||||
importFromClipboard: '設定をクリップボードから読み込む',
|
||||
profile: {
|
||||
account: {
|
||||
profile: 'プロフィール',
|
||||
openProfile: '開く',
|
||||
editProfile: '編集',
|
||||
backupConfig: '設定のバックアップ',
|
||||
save: '保存',
|
||||
restore: '復元',
|
||||
restored: '復元しました',
|
||||
failedToRestore: '復元に失敗しました',
|
||||
},
|
||||
relays: {
|
||||
relays: 'リレー',
|
||||
|
||||
Reference in New Issue
Block a user