feat: column customization

This commit is contained in:
Shusui MOYATANI
2023-05-08 19:58:59 +09:00
parent 5b5c261285
commit f8fbc95ba7
49 changed files with 1178 additions and 307 deletions

View File

@@ -23,10 +23,19 @@ module.exports = {
ecmaFeatures: { jsx: true },
ecmaVersion: 2021,
},
plugins: ['import', 'solid', 'jsx-a11y', 'prettier', '@typescript-eslint', 'tailwindcss'],
plugins: [
'import',
'no-relative-import-paths',
'solid',
'jsx-a11y',
'prettier',
'@typescript-eslint',
'tailwindcss',
],
rules: {
'no-alert': ['off'],
'no-console': ['off'],
'no-relative-import-paths/no-relative-import-paths': ['error', { rootDir: 'src', prefix: '@' }],
'import/extensions': [
'error',
'ignorePackages',

13
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-no-relative-import-paths": "^1.5.2",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-solid": "^0.12.1",
"eslint-plugin-tailwindcss": "^3.11.0",
@@ -3174,6 +3175,12 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/eslint-plugin-no-relative-import-paths": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-no-relative-import-paths/-/eslint-plugin-no-relative-import-paths-1.5.2.tgz",
"integrity": "sha512-wMlL+TVuDhKk1plP+w3L4Hc7+u89vUkrOYq6/0ARjcYqwc9/YaS9uEXNzaqAk+WLoEgakzNL5JgJJw6m4qd5zw==",
"dev": true
},
"node_modules/eslint-plugin-prettier": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz",
@@ -9621,6 +9628,12 @@
"semver": "^6.3.0"
}
},
"eslint-plugin-no-relative-import-paths": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-no-relative-import-paths/-/eslint-plugin-no-relative-import-paths-1.5.2.tgz",
"integrity": "sha512-wMlL+TVuDhKk1plP+w3L4Hc7+u89vUkrOYq6/0ARjcYqwc9/YaS9uEXNzaqAk+WLoEgakzNL5JgJJw6m4qd5zw==",
"dev": true
},
"eslint-plugin-prettier": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz",

View File

@@ -29,6 +29,7 @@
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-no-relative-import-paths": "^1.5.2",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-solid": "^0.12.1",
"eslint-plugin-tailwindcss": "^3.11.0",

View File

@@ -11,28 +11,27 @@ const acceptableLicenses = [
'0BSD',
'BSD-3-Clause',
'CC-BY-4.0',
'Unlicense',
];
const asyncLicenseChecker = (options) => {
return new Promise((resolve, reject) => {
licenseChecker.init(options, (err, data) => {
if (err != null) reject(err);
else resolve(data)
else resolve(data);
});
});
};
export default async function() {
const packageInfo = await util.promisify(fs.readFile)('package.json', { encoding: 'utf8' })
export default async function () {
const packageInfo = await util
.promisify(fs.readFile)('package.json', { encoding: 'utf8' })
.then((data) => JSON.parse(data));
const packages = await asyncLicenseChecker({ start: path.resolve(), production: true });
let ok = true;
const ignorePackageNames = [
packageInfo.name,
'nostr-tools', // nostr-tools is licensed under public domain
];
const ignorePackageNames = [packageInfo.name];
for (const [name, info] of Object.entries(packages)) {
const acceptable = acceptableLicenses.includes(info.licenses);

View File

@@ -2,7 +2,8 @@ import fs from 'fs/promises';
import path from 'path';
import util from 'util';
const readDepFile = (key, filename) => fs.readFile(path.resolve(key, filename), { encoding: 'utf8' });
const readDepFile = (key, filename) =>
fs.readFile(path.resolve(key, filename), { encoding: 'utf8' });
const getPackageInfo = async (key) => {
try {

View File

@@ -5,13 +5,12 @@ import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-squa
import { Event as NostrEvent } from 'nostr-tools';
import ColumnItem from '@/components/ColumnItem';
import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById';
import UserDisplayName from '@/components/UserDisplayName';
import useFormatDate from '@/hooks/useFormatDate';
import useModalState from '@/hooks/useModalState';
import eventWrapper from '@/nostr/event';
import TextNoteDisplayById from './textNote/TextNoteDisplayById';
export type DeprecatedRepostProps = {
event: NostrEvent;
};

View File

@@ -18,7 +18,7 @@ const Modal: Component<ModalProps> = (props) => {
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div
ref={containerRef}
class="absolute top-0 left-0 z-10 flex h-screen w-screen cursor-default place-content-center place-items-center bg-black/30"
class="absolute left-0 top-0 z-10 flex h-screen w-screen cursor-default place-content-center place-items-center bg-black/30"
onClick={handleClickContainer}
>
{props.children}

View File

@@ -1,6 +1,7 @@
import { createSignal, Show, type JSX, Component } from 'solid-js';
import Cog6Tooth from 'heroicons/24/outline/cog-6-tooth.svg';
import Plus from 'heroicons/24/outline/plus.svg';
import MagnifyingGlass from 'heroicons/24/solid/magnifying-glass.svg';
import PencilSquare from 'heroicons/24/solid/pencil-square.svg';
@@ -8,10 +9,13 @@ import Config from '@/components/modal/Config';
import NotePostForm from '@/components/NotePostForm';
import useConfig from '@/core/useConfig';
import { useHandleCommand } from '@/hooks/useCommandBus';
import useModalState from '@/hooks/useModalState';
import resolveAsset from '@/utils/resolveAsset';
const SideBar: Component = () => {
let textAreaRef: HTMLTextAreaElement | undefined;
const { showAddColumn, showAbout } = useModalState();
const { config } = useConfig();
const [formOpened, setFormOpened] = createSignal(false);
const [configOpened, setConfigOpened] = createSignal(false);
@@ -39,7 +43,7 @@ const SideBar: Component = () => {
<div class="flex w-14 flex-auto flex-col items-center gap-3 border-r border-rose-200 pt-5">
<div class="flex flex-col items-center gap-3">
<button
class="h-9 w-9 rounded-full border border-primary bg-primary p-2 text-2xl font-bold text-white"
class="h-9 w-9 rounded-full border border-primary bg-primary p-2 text-2xl text-white"
onClick={() => toggleForm()}
>
<PencilSquare />
@@ -49,17 +53,28 @@ const SideBar: Component = () => {
<MagnifyingGlass />
</button>
*/}
{/* <div>column 1</div> */}
{/* <div>column 2</div> */}
</div>
<div class="grow" />
<div>
<div class="flex flex-col items-center pb-2">
<button
class="h-12 w-12 p-3 text-primary"
class="h-10 w-12 rounded-full p-3 text-2xl text-primary"
onClick={() => showAddColumn()}
>
<Plus />
</button>
<button
class="h-10 w-12 p-3 text-primary"
onClick={() => setConfigOpened((current) => !current)}
>
<Cog6Tooth />
</button>
<button class="pt-2" onClick={() => showAbout()}>
<img
class="h-8 w-8"
src={resolveAsset('/images/rabbit_app_256.png')}
alt="About rabbit"
/>
</button>
</div>
</div>
<div

View File

@@ -1,10 +1,9 @@
import { Show, type Component } from 'solid-js';
import ColumnItem from '@/components/ColumnItem';
import TextNoteDisplay, { TextNoteDisplayProps } from '@/components/textNote/TextNoteDisplay';
import useConfig from '@/core/useConfig';
import TextNoteDisplay, { TextNoteDisplayProps } from './textNote/TextNoteDisplay';
export type TextNoteProps = TextNoteDisplayProps;
const TextNote: Component<TextNoteProps> = (props) => {

View File

@@ -0,0 +1,35 @@
import { Component, JSX, Show, createSignal } from 'solid-js';
import EllipsisVertical from 'heroicons/24/outline/ellipsis-vertical.svg';
export type BasicColumnHeaderProps = {
name: string;
icon?: JSX.Element;
settings: () => JSX.Element;
onClose?: () => void;
};
const BasicColumnHeader: Component<BasicColumnHeaderProps> = (props) => {
const [isSettingsOpened, setIsSettingOpened] = createSignal(false);
const toggleSettingsOpened = () => setIsSettingOpened((current) => !current);
return (
<div class="flex flex-col">
<div class="flex h-8 items-center gap-1 px-2">
<h2 class="flex flex-1 items-center gap-1">
<Show when={props.icon} keyed>
{(icon) => <span class="inline-block h-4 w-4 text-gray-700">{icon}</span>}
</Show>
<span class="column-name">{props.name}</span>
</h2>
<button class="h-4 w-4" onClick={() => toggleSettingsOpened()}>
<EllipsisVertical />
</button>
</div>
<Show when={isSettingsOpened()}>{props.settings()}</Show>
</div>
);
};
export default BasicColumnHeader;

View File

@@ -2,15 +2,15 @@ import { Show, type JSX, type Component } from 'solid-js';
import ArrowLeft from 'heroicons/24/outline/arrow-left.svg';
import TimelineContentDisplay from '@/components/TimelineContentDisplay';
import { TimelineContext, useTimelineState } from '@/components/TimelineContext';
import TimelineContentDisplay from '@/components/timeline/TimelineContentDisplay';
import { TimelineContext, useTimelineState } from '@/components/timeline/TimelineContext';
import { useHandleCommand } from '@/hooks/useCommandBus';
export type ColumnProps = {
name: string;
columnIndex: number;
lastColumn?: true;
lastColumn: boolean;
width: 'widest' | 'wide' | 'medium' | 'narrow' | null | undefined;
header: JSX.Element;
children: JSX.Element;
};
@@ -46,9 +46,9 @@ const Column: Component<ColumnProps> = (props) => {
class="flex w-[80vw] shrink-0 snap-center snap-always flex-col border-r sm:snap-align-none"
classList={{
'sm:w-[500px]': width() === 'widest',
'sm:w-[350px]': width() === 'wide',
'sm:w-[310px]': width() === 'medium',
'sm:w-[270px]': width() === 'narrow',
'sm:w-[360px]': width() === 'wide',
'sm:w-[320px]': width() === 'medium',
'sm:w-[280px]': width() === 'narrow',
}}
>
<Show
@@ -56,17 +56,14 @@ const Column: Component<ColumnProps> = (props) => {
keyed
fallback={
<>
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
{/* <span class="column-icon">🏠</span> */}
<span class="column-name">{props.name}</span>
</div>
<div class="shrink-0 border-b">{props.header}</div>
<div class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</div>
</>
}
>
{(timeline) => (
<div class="h-full w-full bg-white">
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2">
<div class="flex shrink-0 items-center border-b bg-white px-2">
<button
class="flex w-full items-center gap-1"
onClick={() => timelineState?.clearTimeline()}

View File

@@ -0,0 +1,101 @@
import { Component, JSX } from 'solid-js';
import ChevronLeft from 'heroicons/24/outline/chevron-left.svg';
import ChevronRight from 'heroicons/24/outline/chevron-right.svg';
import Trash from 'heroicons/24/outline/trash.svg';
import { ColumnType } from '@/core/column';
import useConfig from '@/core/useConfig';
import { useRequestCommand } from '@/hooks/useCommandBus';
type ColumnSettingsProps = {
column: ColumnType;
columnIndex: number;
};
type ColumnSettingsSectionProps = {
title: string;
children: JSX.Element;
};
const ColumnSettingsSection: Component<ColumnSettingsSectionProps> = (props) => {
return (
<div class="flex flex-col gap-2 border-b p-2">
<div>{props.title}</div>
<div>{props.children}</div>
</div>
);
};
const ColumnSettings: Component<ColumnSettingsProps> = (props) => {
const { saveColumn, removeColumn, moveColumn } = useConfig();
const requestCommand = useRequestCommand();
const setColumnWidth = (width: ColumnType['width']) => {
saveColumn({ ...props.column, width });
};
const move = (index: number) => {
moveColumn(props.column.id, index);
requestCommand({ command: 'moveToColumn', columnIndex: index }).catch((err) =>
console.error(err),
);
};
return (
<div class="flex flex-col border-t">
<ColumnSettingsSection title="カラム幅">
<div class="flex h-9 gap-2">
<button
class="rounded-md border px-4 hover:bg-stone-100"
onClick={() => setColumnWidth('widest')}
>
</button>
<button
class="rounded-md border px-4 hover:bg-stone-100"
onClick={() => setColumnWidth('wide')}
>
</button>
<button
class="rounded-md border px-4 hover:bg-stone-100"
onClick={() => setColumnWidth('medium')}
>
</button>
<button
class="rounded-md border px-4 hover:bg-stone-100"
onClick={() => setColumnWidth('narrow')}
>
</button>
</div>
</ColumnSettingsSection>
<div class="flex h-10 items-center gap-2">
<button class="py-4 pl-2" title="左に移動" onClick={() => move(props.columnIndex - 1)}>
<span class="inline-block h-4 w-4">
<ChevronLeft />
</span>
</button>
<button class="py-4 pr-2" title="右に移動" onClick={() => move(props.columnIndex + 1)}>
<span class="inline-block h-4 w-4">
<ChevronRight />
</span>
</button>
<div class="flex-1" />
<button
class="px-2 py-4 text-rose-500 hover:text-rose-600"
title="削除"
onClick={() => removeColumn(props.column.id)}
>
<span class="inline-block h-4 w-4" aria-label="削除">
<Trash />
</span>
</button>
</div>
</div>
);
};
export default ColumnSettings;

View File

@@ -0,0 +1,74 @@
import { For, Switch, Match } from 'solid-js';
import FollowingColumn from '@/components/column/FollwingColumn';
import NotificationColumn from '@/components/column/NotificationColumn';
import PostsColumn from '@/components/column/PostsColumn';
import ReactionsColumn from '@/components/column/ReactionsColumn';
import RelaysColumn from '@/components/column/RelaysColumn';
import useConfig from '@/core/useConfig';
const Columns = () => {
const { config } = useConfig();
return (
<div class="flex h-full snap-x snap-mandatory flex-row overflow-y-hidden overflow-x-scroll">
<For each={config().columns}>
{(column, index) => {
const columnIndex = () => index() + 1;
const lastColumn = () => columnIndex() === config().columns.length;
return (
<Switch>
<Match when={column.columnType === 'Following' && column} keyed>
{(followingColumn) => (
<FollowingColumn
column={followingColumn}
columnIndex={columnIndex()}
lastColumn={lastColumn()}
/>
)}
</Match>
<Match when={column.columnType === 'Notification' && column} keyed>
{(notificationColumn) => (
<NotificationColumn
column={notificationColumn}
columnIndex={columnIndex()}
lastColumn={lastColumn()}
/>
)}
</Match>
<Match when={column.columnType === 'Posts' && column} keyed>
{(postsColumn) => (
<PostsColumn
column={postsColumn}
columnIndex={columnIndex()}
lastColumn={lastColumn()}
/>
)}
</Match>
<Match when={column.columnType === 'Reactions' && column} keyed>
{(reactionsColumn) => (
<ReactionsColumn
column={reactionsColumn}
columnIndex={columnIndex()}
lastColumn={lastColumn()}
/>
)}
</Match>
<Match when={column.columnType === 'Relays' && column} keyed>
{(reactionsColumn) => (
<RelaysColumn
column={reactionsColumn}
columnIndex={columnIndex()}
lastColumn={lastColumn()}
/>
)}
</Match>
</Switch>
);
}}
</For>
</div>
);
};
export default Columns;

View File

@@ -0,0 +1,67 @@
import { Component } from 'solid-js';
import Home from 'heroicons/24/outline/home.svg';
import { uniq } from 'lodash';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import Timeline from '@/components/timeline/Timeline';
import { FollowingColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
import useConfig from '@/core/useConfig';
import { useFollowings } from '@/nostr/useBatchedEvents';
import useSubscription from '@/nostr/useSubscription';
import epoch from '@/utils/epoch';
type FollowingColumnDisplayProps = {
columnIndex: number;
lastColumn: boolean;
column: FollowingColumnType;
};
const FollowingColumn: Component<FollowingColumnDisplayProps> = (props) => {
const { config, removeColumn } = useConfig();
const { followingPubkeys } = useFollowings(() => ({ pubkey: props.column.pubkey }));
const { events: followingsPosts } = useSubscription(() => {
const authors = uniq([...followingPubkeys()]);
if (authors.length === 0) return null;
return {
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6],
authors,
limit: 10,
since: epoch() - 4 * 60 * 60,
},
],
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
};
});
return (
<Column
header={
<BasicColumnHeader
name={props.column.name ?? 'ホーム'}
icon={<Home />}
settings={() => <ColumnSettings column={props.column} columnIndex={props.columnIndex} />}
onClose={() => removeColumn(props.column.id)}
/>
}
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
>
<Timeline events={followingsPosts()} />
</Column>
);
};
export default FollowingColumn;

View File

@@ -0,0 +1,57 @@
import { Component } from 'solid-js';
import Bell from 'heroicons/24/outline/bell.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import Notification from '@/components/timeline/Notification';
import { NotificationColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
import useConfig from '@/core/useConfig';
import useSubscription from '@/nostr/useSubscription';
type NotificationColumnDisplayProps = {
columnIndex: number;
lastColumn: boolean;
column: NotificationColumnType;
};
const NotificationColumn: Component<NotificationColumnDisplayProps> = (props) => {
const { config, removeColumn } = useConfig();
const { events: notifications } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6, 7],
'#p': [props.column.pubkey],
limit: 10,
},
],
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
return (
<Column
header={
<BasicColumnHeader
name={props.column.name ?? '通知'}
icon={<Bell />}
settings={() => <ColumnSettings column={props.column} columnIndex={props.columnIndex} />}
onClose={() => removeColumn(props.column.id)}
/>
}
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
>
<Notification events={notifications()} />
</Column>
);
};
export default NotificationColumn;

View File

@@ -0,0 +1,57 @@
import { Component } from 'solid-js';
import User from 'heroicons/24/outline/user.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import Timeline from '@/components/timeline/Timeline';
import { PostsColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
import useConfig from '@/core/useConfig';
import useSubscription from '@/nostr/useSubscription';
type PostsColumnDisplayProps = {
columnIndex: number;
lastColumn: boolean;
column: PostsColumnType;
};
const PostsColumn: Component<PostsColumnDisplayProps> = (props) => {
const { config, removeColumn } = useConfig();
const { events } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6],
authors: [props.column.pubkey],
limit: 10,
},
],
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
return (
<Column
header={
<BasicColumnHeader
name={props.column.name ?? '投稿'}
icon={<User />}
settings={() => <ColumnSettings column={props.column} columnIndex={props.columnIndex} />}
onClose={() => removeColumn(props.column.id)}
/>
}
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
>
<Timeline events={events()} />
</Column>
);
};
export default PostsColumn;

View File

@@ -0,0 +1,57 @@
import { Component } from 'solid-js';
import Heart from 'heroicons/24/outline/heart.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import Notification from '@/components/timeline/Notification';
import { ReactionsColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
import useConfig from '@/core/useConfig';
import useSubscription from '@/nostr/useSubscription';
type ReactionsColumnDisplayProps = {
columnIndex: number;
lastColumn: boolean;
column: ReactionsColumnType;
};
const ReactionsColumn: Component<ReactionsColumnDisplayProps> = (props) => {
const { config, removeColumn } = useConfig();
const { events: reactions } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [7],
authors: [props.column.pubkey],
limit: 10,
},
],
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
return (
<Column
header={
<BasicColumnHeader
name={props.column.name ?? 'リアクション'}
icon={<Heart />}
settings={() => <ColumnSettings column={props.column} columnIndex={props.columnIndex} />}
onClose={() => removeColumn(props.column.id)}
/>
}
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
>
<Notification events={reactions()} />
</Column>
);
};
export default ReactionsColumn;

View File

@@ -0,0 +1,58 @@
import { Component } from 'solid-js';
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import Timeline from '@/components/timeline/Timeline';
import { RelaysColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
import useConfig from '@/core/useConfig';
import useSubscription from '@/nostr/useSubscription';
import epoch from '@/utils/epoch';
type RelaysColumnDisplayProps = {
columnIndex: number;
lastColumn: boolean;
column: RelaysColumnType;
};
const RelaysColumn: Component<RelaysColumnDisplayProps> = (props) => {
const { removeColumn } = useConfig();
const { events } = useSubscription(() => ({
relayUrls: props.column.relayUrls,
filters: [
{
kinds: [1, 6],
limit: 25,
since: epoch() - 4 * 60 * 60,
},
],
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
return (
<Column
header={
<BasicColumnHeader
name={props.column.name ?? 'リレー'}
icon={<GlobeAlt />}
settings={() => <ColumnSettings column={props.column} columnIndex={props.columnIndex} />}
onClose={() => removeColumn(props.column.id)}
/>
}
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
>
<Timeline events={events()} />
</Column>
);
};
export default RelaysColumn;

View File

@@ -0,0 +1,58 @@
import { Component } from 'solid-js';
import MagnifyingGlass from 'heroicons/24/outline/magnifying-glass.svg';
import BasicColumnHeader from '@/components/column/BasicColumnHeader';
import Column from '@/components/column/Column';
import ColumnSettings from '@/components/column/ColumnSettings';
import Timeline from '@/components/timeline/Timeline';
import { SearchColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
import { relaysForSearching } from '@/core/relayUrls';
import useConfig from '@/core/useConfig';
import useSubscription from '@/nostr/useSubscription';
type SearchColumnDisplayProps = {
columnIndex: number;
lastColumn: boolean;
column: SearchColumnType;
};
const SearchColumn: Component<SearchColumnDisplayProps> = (props) => {
const { removeColumn } = useConfig();
const { events } = useSubscription(() => ({
relayUrls: relaysForSearching,
filters: [
{
kinds: [1, 6],
search: props.column.query,
limit: 25,
},
],
clientEventFilter: (event) => {
if (props.column.contentFilter == null) return true;
return applyContentFilter(props.column.contentFilter)(event.content);
},
}));
return (
<Column
header={
<BasicColumnHeader
name={props.column.name ?? '検索'}
icon={<MagnifyingGlass />}
settings={() => <ColumnSettings column={props.column} columnIndex={props.columnIndex} />}
onClose={() => removeColumn(props.column.id)}
/>
}
width={props.column.width}
columnIndex={props.columnIndex}
lastColumn={props.lastColumn}
>
<Timeline events={events()} />
</Column>
);
};
export default SearchColumn;

View File

@@ -0,0 +1,104 @@
import { Component, createResource, For, Show } from 'solid-js';
import BasicModal from '@/components/modal/BasicModal';
import resolveAsset from '@/utils/resolveAsset';
type AboutProps = {
onClose: () => void;
};
type PackageInfo = {
self: {
name: string;
author: string;
version: string;
homepage: string;
licenseSpdx: string;
licenseText: string;
};
packages: {
name: string;
version: string;
licenseSpdx: string;
licenseText: string;
}[];
};
const fetchPackageInfo = async (): Promise<PackageInfo> => {
const res = await fetch(resolveAsset('packageInfo.json'));
const body = await res.text();
return JSON.parse(body) as PackageInfo;
};
const commit = import.meta.env.COMMIT as string | null;
const About: Component<AboutProps> = (props) => {
const [packageInfo] = createResource(fetchPackageInfo);
return (
<BasicModal onClose={props.onClose}>
<div class="p-8">
<div class="flex flex-col items-center pt-8">
<img src={resolveAsset('/images/rabbit_app_256.png')} alt="Logo" width="64" height="64" />
<h1 class="my-4">
Rabbit <span id="app-version">v{packageInfo()?.self?.version}</span>
</h1>
</div>
<h2 class="my-4 text-xl font-bold"></h2>
<p class="my-4">Copyright (C) 2023 Shusui Moyatani</p>
<p class="my-4">
GNUアフェロー一般公衆ライセンス3()
</p>
<p class="my-4">
<strong class="font-bold"></strong><em></em>
<em></em>
GNUアフェロー一般公衆ライセンスをご覧ください
</p>
<p class="my-4">
GNUアフェロー一般公衆ライセンスのコピーを受け取っていることでしょう
<a class="link" href="https://www.gnu.org/licenses/">
https://www.gnu.org/licenses/
</a>
</p>
<a class="text-blue-500 underline" href="https://gpl.mhatta.org/agpl.ja.html">
</a>
<pre class="max-h-96 overflow-scroll rounded bg-zinc-100 p-4 text-xs">
{packageInfo()?.self.licenseText}
</pre>
<h2 class="my-4 text-xl font-bold">使</h2>
<For each={packageInfo()?.packages ?? []} fallback="取得中">
{(p) => {
return (
<>
<h3 class="mb-2 mt-4 font-mono">
{p.name}@{p.version} ({p.licenseSpdx})
</h3>
<pre class="max-h-96 overflow-scroll rounded bg-zinc-100 p-4 text-xs">
{p.licenseText}
</pre>
</>
);
}}
</For>
</div>
</BasicModal>
);
};
export default About;

View File

@@ -1,20 +1,113 @@
import BasicModal from '@/components/modal/BasicModal';
import useConfig from '@/core/useConfig';
import { Component } from 'solid-js';
const AddColumn = () => {
const { addColumn } = useConfig();
import Bell from 'heroicons/24/outline/bell.svg';
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import Heart from 'heroicons/24/outline/heart.svg';
import Home from 'heroicons/24/outline/home.svg';
import User from 'heroicons/24/outline/user.svg';
import BasicModal from '@/components/modal/BasicModal';
import {
createFollowingColumn,
createJapanRelaysColumn,
createNotificationColumn,
createPostsColumn,
createReactionsColumn,
} from '@/core/column';
import useConfig from '@/core/useConfig';
import usePubkey from '@/nostr/usePubkey';
import ensureNonNull from '@/utils/ensureNonNull';
type AddColumnProps = {
onClose: () => void;
};
const AddColumn: Component<AddColumnProps> = (props) => {
const pubkey = usePubkey();
const { saveColumn } = useConfig();
const addFollowingColumn = () => {
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => {
saveColumn(createFollowingColumn({ pubkey: pubkeyNonNull }));
});
props.onClose();
};
const addNotificationColumn = () => {
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => {
saveColumn(createNotificationColumn({ pubkey: pubkeyNonNull }));
});
props.onClose();
};
const addJapanRelaysColumn = () => {
saveColumn(createJapanRelaysColumn());
props.onClose();
};
const addMyPostsColumn = () => {
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => {
saveColumn(createPostsColumn({ pubkey: pubkeyNonNull }));
});
props.onClose();
};
const addMyReactionsColumn = () => {
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => {
saveColumn(createReactionsColumn({ pubkey: pubkeyNonNull }));
});
props.onClose();
};
return (
<BasicModal onClose={() => console.log('closed')}>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<BasicModal onClose={props.onClose}>
<div class="flex flex-wrap p-4">
<button
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
onClick={() => addFollowingColumn()}
>
<span class="inline-block h-8 w-8">
<Home />
</span>
</button>
<button
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
onClick={() => addNotificationColumn()}
>
<span class="inline-block h-8 w-8">
<Bell />
</span>
</button>
<button
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
onClick={() => addJapanRelaysColumn()}
>
<span class="inline-block h-8 w-8">
<GlobeAlt />
</span>
</button>
<button
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
onClick={() => addMyPostsColumn()}
>
<span class="inline-block h-8 w-8">
<User />
</span>
稿
</button>
<button
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
onClick={() => addMyReactionsColumn()}
>
<span class="inline-block h-8 w-8">
<Heart />
</span>
</button>
</div>
</BasicModal>
);
};

View File

@@ -13,7 +13,7 @@ export type BasicModalProps = {
const BasicModal: Component<BasicModalProps> = (props) => {
return (
<Modal onClose={() => props.onClose?.()}>
<div class="h-screen w-[640px] max-w-full">
<div class="h-full w-[640px] max-w-full">
<button
class="w-full pt-1 text-start text-stone-800"
aria-label="Close"

View File

@@ -0,0 +1,45 @@
import { Show, Switch, Match } from 'solid-js';
import About from '@/components/modal/About';
import AddColumn from '@/components/modal/AddColumn';
import ProfileDisplay from '@/components/modal/ProfileDisplay';
import ProfileEdit from '@/components/modal/ProfileEdit';
import useModalState from '@/hooks/useModalState';
import usePubkey from '@/nostr/usePubkey';
import ensureNonNull from '@/utils/ensureNonNull';
const GlobalModal = () => {
const pubkey = usePubkey();
const { modalState, showProfile, closeModal } = useModalState();
return (
<Show when={modalState()} keyed>
{(state) => (
<Switch>
<Match when={state.type === 'Profile' && state.pubkey} keyed>
{(pubkeyNonNull: string) => (
<ProfileDisplay pubkey={pubkeyNonNull} onClose={closeModal} />
)}
</Match>
<Match when={state.type === 'ProfileEdit'} keyed>
<ProfileEdit
onClose={() =>
ensureNonNull([pubkey()])(([pubkeyNonNull]) => {
showProfile(pubkeyNonNull);
})
}
/>
</Match>
<Match when={state.type === 'AddColumn'}>
<AddColumn onClose={closeModal} />
</Match>
<Match when={state.type === 'About'}>
<About onClose={closeModal} />
</Match>
</Switch>
)}
</Show>
);
};
export default GlobalModal;

View File

@@ -10,7 +10,7 @@ import uniq from 'lodash/uniq';
import ContextMenu, { MenuItem } from '@/components/ContextMenu';
import BasicModal from '@/components/modal/BasicModal';
import Timeline from '@/components/Timeline';
import Timeline from '@/components/timeline/Timeline';
import SafeLink from '@/components/utils/SafeLink';
import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState';
@@ -236,7 +236,7 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
<Match when={following()}>
<button
class="rounded-full border border-primary bg-primary px-4 py-2
text-center font-bold text-white hover:bg-rose-500 sm:w-32"
text-center font-bold text-white hover:bg-rose-500 sm:w-36"
onMouseEnter={() => setHoverFollowButton(true)}
onMouseLeave={() => setHoverFollowButton(false)}
onClick={() => unfollow()}
@@ -249,7 +249,7 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
</Match>
<Match when={!following()}>
<button
class="w-24 rounded-full border border-primary px-4 py-2 text-primary
class="w-28 rounded-full border border-primary px-4 py-2 text-primary
hover:border-rose-400 hover:text-rose-400"
onClick={() => follow()}
disabled={updateContactsMutation.isLoading}

View File

@@ -1,9 +1,8 @@
import { Component, createSignal, Show } from 'solid-js';
import SafeLink from '@/components/utils/SafeLink';
import { fixUrl } from '@/utils/imageUrl';
import SafeLink from '../utils/SafeLink';
type ImageDisplayProps = {
url: string;
initialHidden: boolean;

View File

@@ -1,11 +1,10 @@
import { Show } from 'solid-js';
import EventLink from '@/components/EventLink';
// eslint-disable-next-line import/no-cycle
import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById';
import { type MentionedEvent } from '@/nostr/parseTextNote';
import EventLink from '../EventLink';
export type MentionedEventDisplayProps = {
mentionedEvent: MentionedEvent;
};
@@ -14,7 +13,7 @@ const MentionedEventDisplay = (props: MentionedEventDisplayProps) => {
return (
<Show
when={props.mentionedEvent.marker != null && props.mentionedEvent.marker.length > 0}
fallback={() => <EventLink eventId={props.mentionedEvent.eventId} />}
fallback={<EventLink eventId={props.mentionedEvent.eventId} />}
>
<div class="my-1 rounded border p-1">
<TextNoteDisplayById

View File

@@ -11,11 +11,10 @@ import SafeLink from '@/components/utils/SafeLink';
import useConfig from '@/core/useConfig';
import eventWrapper from '@/nostr/event';
import parseTextNote, { resolveTagReference, type ParsedTextNoteNode } from '@/nostr/parseTextNote';
import { isImageUrl } from '@/utils/imageUrl';
import type { Event as NostrEvent } from 'nostr-tools';
import { isImageUrl } from '@/utils/imageUrl';
export type TextNoteContentDisplayProps = {
event: NostrEvent;
embedding: boolean;
@@ -47,7 +46,7 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
if (item.data.type === 'note' && props.embedding) {
return (
<div class="my-1 rounded border p-1">
<TextNoteDisplayById eventId={item.data.data} actions={false} />
<TextNoteDisplayById eventId={item.data.data} actions={false} embedding={false} />
</div>
);
}

View File

@@ -8,13 +8,14 @@ import HeartOutlined from 'heroicons/24/outline/heart.svg';
import HeartSolid from 'heroicons/24/solid/heart.svg';
import { nip19, type Event as NostrEvent } from 'nostr-tools';
import ContextMenu, { MenuItem } from '@/components/ContextMenu';
import NotePostForm from '@/components/NotePostForm';
import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
// eslint-disable-next-line import/no-cycle
import TextNoteContentDisplay from '@/components/textNote/TextNoteContentDisplay';
import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById';
import { useTimelineContext } from '@/components/TimelineContext';
import { useTimelineContext } from '@/components/timeline/TimelineContext';
import useConfig from '@/core/useConfig';
import useFormatDate from '@/hooks/useFormatDate';
import useModalState from '@/hooks/useModalState';
@@ -29,8 +30,6 @@ import ensureNonNull from '@/utils/ensureNonNull';
import npubEncodeFallback from '@/utils/npubEncodeFallback';
import timeout from '@/utils/timeout';
import ContextMenu, { MenuItem } from '../ContextMenu';
export type TextNoteDisplayProps = {
event: NostrEvent;
embedding?: boolean;

View File

@@ -1,13 +1,12 @@
import { Switch, Match, type Component } from 'solid-js';
import EventLink from '@/components/EventLink';
// eslint-disable-next-line import/no-cycle
import TextNoteDisplay, { type TextNoteDisplayProps } from '@/components/textNote/TextNoteDisplay';
import useConfig from '@/core/useConfig';
import useEvent from '@/nostr/useEvent';
import ensureNonNull from '@/utils/ensureNonNull';
import EventLink from '../EventLink';
type TextNoteDisplayByIdProps = Omit<TextNoteDisplayProps, 'event'> & {
eventId: string | undefined;
};

View File

@@ -3,8 +3,8 @@ import { Switch, Match, type Component } from 'solid-js';
import uniq from 'lodash/uniq';
import { Filter, Event as NostrEvent } from 'nostr-tools';
import Timeline from '@/components/Timeline';
import { type TimelineContent } from '@/components/TimelineContext';
import Timeline from '@/components/timeline/Timeline';
import { type TimelineContent } from '@/components/timeline/TimelineContext';
import useConfig from '@/core/useConfig';
import eventWrapper from '@/nostr/event';
import useSubscription from '@/nostr/useSubscription';

View File

@@ -1,7 +1,10 @@
// import { z } from 'zod';
import { type Filter } from 'nostr-tools';
import { type ColumnProps } from '@/components/Column';
import { type ColumnProps } from '@/components/column/Column';
import { ContentFilter } from '@/core/contentFilter';
import { relaysOnlyAvailableInJP } from '@/core/relayUrls';
import generateId from '@/utils/generateId';
export type NotificationType =
// The event which includes ["p", ...] tags.
@@ -44,51 +47,120 @@ type BulidOptions = {
export type BaseColumn = {
id: string;
title: string;
name?: string;
width: ColumnProps['width'];
contentFilter?: ContentFilter;
};
/** A column which shows posts by following users */
export type FollowingColumn = BaseColumn & {
export type FollowingColumnType = BaseColumn & {
columnType: 'Following';
pubkey: string;
};
/** A column which shows replies, reactions, reposts to the specific user */
export type NotificationColumn = BaseColumn & {
export type NotificationColumnType = BaseColumn & {
columnType: 'Notification';
// notificationTypes: NotificationType[];
pubkey: string;
};
/** A column which shows posts from the specific user */
export type PostsColumn = BaseColumn & {
export type PostsColumnType = BaseColumn & {
columnType: 'Posts';
pubkey: string;
};
/** A column which shows reactions published by the specific user */
export type ReactionsColumn = BaseColumn & {
export type ReactionsColumnType = BaseColumn & {
columnType: 'Reactions';
pubkey: string;
};
/** A column which shows text notes and reposts posted to the specific relays */
export type GlobalColumn = BaseColumn & {
columnType: 'Global';
export type RelaysColumnType = BaseColumn & {
columnType: 'Relays';
relayUrls: string[];
};
/** A column which search text notes from relays which support NIP-50 */
export type SearchColumnType = BaseColumn & {
columnType: 'Search';
query: string;
};
/** A column which shows text notes and reposts posted to the specific relays */
export type CustomFilterColumn = BaseColumn & {
export type CustomFilterColumnType = BaseColumn & {
columnType: 'CustomFilter';
filters: Filter[];
};
export type ColumnConfig =
| FollowingColumn
| NotificationColumn
| GlobalColumn
| PostsColumn
| ReactionsColumn
| CustomFilterColumn;
export type ColumnType =
| FollowingColumnType
| NotificationColumnType
| RelaysColumnType
| PostsColumnType
| ReactionsColumnType
| SearchColumnType
| CustomFilterColumnType;
type CreateParams<T extends BaseColumn> = Omit<T, keyof BaseColumn | 'columnType'> &
Partial<BaseColumn>;
export const createBaseColumn = (): BaseColumn => ({
id: generateId(),
width: 'medium',
});
export const createFollowingColumn = (
params: CreateParams<FollowingColumnType>,
): FollowingColumnType => ({
...createBaseColumn(),
columnType: 'Following',
...params,
});
export const createNotificationColumn = (
params: CreateParams<NotificationColumnType>,
): NotificationColumnType => ({
...createBaseColumn(),
columnType: 'Notification',
...params,
});
export const createRelaysColumn = (params: CreateParams<RelaysColumnType>): RelaysColumnType => ({
...createBaseColumn(),
columnType: 'Relays',
...params,
});
export const createJapanRelaysColumn = () =>
createRelaysColumn({
name: '日本語',
relayUrls: relaysOnlyAvailableInJP,
contentFilter: {
filterType: 'Regex',
regex: '[\\p{scx=Hiragana}\\p{scx=Katakana}]',
flag: 'u',
},
});
export const createPostsColumn = (params: CreateParams<PostsColumnType>): PostsColumnType => ({
...createBaseColumn(),
columnType: 'Posts',
...params,
});
export const createReactionsColumn = (
params: CreateParams<ReactionsColumnType>,
): ReactionsColumnType => ({
...createBaseColumn(),
columnType: 'Reactions',
...params,
});
export const createSearchColumn = (params: CreateParams<SearchColumnType>): SearchColumnType => ({
...createBaseColumn(),
columnType: 'Search',
...params,
});

View File

@@ -0,0 +1,19 @@
// NORMAL (implicit AND)
// A filter 'hello world' should match 'hello world'
// A filter "Lorem amet" should match "Lorem ipsum dolor sit amet"
//
// AND
// A filter "Lorem AND amet" should match "Lorem ipsum dolor sit amet"
// A filter "Lorem AND dolor AND amet" should match "Lorem ipsum dolor sit amet"
// A filter "Lorem AND ZZZ" should not match "Lorem ipsum dolor sit amet"
//
// CASE
// A filter 'hello world' should match 'HELLO WORLD'
// A filter 'HELLO WORLD' should match 'hello world'
//
// DOUBLEQUOTE
// A filter '"HELLO WORLD"' should match 'HELLO WORLD'
// A filter '"HELLO WORLD"' should not match 'hello world'
import { assert, it } from 'vitest';
it('TODO', () => assert(true));

50
src/core/contentFilter.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Content filter is a text which expresses the filter.
*
* - And condition
* - The text "AND" (upper case) or white space means an "and" condition (&&).
* - Examples:
* hello AND world
* hello world
* - Or condition
* - OR (upper case) means an "or" condition (&&).
* - Examples:
* hello OR world
* - Grouping
* ()
* - Normal text
* Case-insensitive
* helloworld,
* - Double quotes
* Case-sensitive
* "Hello World"
*/
export type ContentFilterAnd = { filterType: 'AND'; children: ContentFilter[] };
export type ContentFilterOr = { filterType: 'OR'; children: ContentFilter[] };
export type ContentFilterTextInclude = { filterType: 'Text'; text: string };
export type ContentFilterRegex = { filterType: 'Regex'; regex: string; flag: string };
export type ContentFilter =
| ContentFilterAnd
| ContentFilterOr
| ContentFilterTextInclude
| ContentFilterRegex;
export const applyContentFilter =
(filter: ContentFilter) =>
(content: string): boolean => {
switch (filter.filterType) {
case 'AND':
return filter.children.every((child) => applyContentFilter(child)(content));
case 'OR':
return filter.children.some((child) => applyContentFilter(child)(content));
case 'Text':
return content.includes(filter.text);
case 'Regex':
return new RegExp(filter.regex, filter.flag).test(content);
default:
console.error('unsupported content filter type');
break;
}
return false;
};

23
src/core/relayUrls.ts Normal file
View File

@@ -0,0 +1,23 @@
export const relaysGlobal: string[] = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social',
];
export const relaysOnlyAvailableInJP: string[] = [
'wss://relay-jp.nostr.wirednet.jp',
'wss://nostr.h3z.jp',
'wss://nostr.holybea.com',
];
export const relaysInJP: string[] = [
...relaysOnlyAvailableInJP,
'wss://nostr.holybea.com',
'wss://nostr-relay.nokotaro.com',
];
export const relaysForSearching: string[] = [
'wss://relay.nostr.band',
'wss://nostrja-kari-nip50.heguro.com',
'wss://search.nos.today',
];

View File

@@ -3,16 +3,23 @@ import { type Accessor, type Setter } from 'solid-js';
import uniq from 'lodash/uniq';
import { Kind, type Event as NostrEvent } from 'nostr-tools';
import { ColumnConfig } from '@/core/column';
import {
ColumnType,
createFollowingColumn,
createJapanRelaysColumn,
createNotificationColumn,
createPostsColumn,
createReactionsColumn,
} from '@/core/column';
import { relaysGlobal, relaysInJP } from '@/core/relayUrls';
import {
createStorageWithSerializer,
createStoreWithStorage,
} from '@/hooks/createSignalWithStorage';
import generateId from '@/utils/generateId';
export type Config = {
relayUrls: string[];
columns: ColumnConfig[];
columns: ColumnType[];
dateFormat: 'relative' | 'absolute-long' | 'absolute-short';
keepOpenPostForm: boolean;
showImage: boolean;
@@ -24,44 +31,29 @@ export type Config = {
type UseConfig = {
config: Accessor<Config>;
setConfig: Setter<Config>;
// relay
addRelay: (url: string) => void;
removeRelay: (url: string) => void;
// mute
addMutedPubkey: (pubkey: string) => void;
removeMutedPubkey: (pubkey: string) => void;
addMutedKeyword: (keyword: string) => void;
removeMutedKeyword: (keyword: string) => void;
addColumn: (column: ColumnConfig) => void;
// column
saveColumn: (column: ColumnType) => void;
moveColumn: (columnId: string, index: number) => void;
removeColumn: (columnId: string) => void;
// functions
isPubkeyMuted: (pubkey: string) => boolean;
shouldMuteEvent: (event: NostrEvent) => boolean;
initializeColumns: (param: { pubkey: string }) => void;
};
const relaysGlobal = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social',
'wss://relay.current.fyi',
];
const relaysOnlyAvailableInJP = [
'wss://relay-jp.nostr.wirednet.jp',
'wss://nostr.h3z.jp',
'wss://nostr.holybea.com',
];
const relaysInJP = [
...relaysOnlyAvailableInJP,
'wss://nostr.holybea.com',
'wss://nostr-relay.nokotaro.com',
];
const initialRelays = (): string[] => {
const relayUrls = [...relaysGlobal];
if (navigator.language === 'ja') {
if (window.navigator.language.includes('ja')) {
relayUrls.push(...relaysInJP);
}
return relayUrls;
};
@@ -112,8 +104,32 @@ const useConfig = (): UseConfig => {
setConfig('mutedKeywords', (current) => current.filter((e) => e !== keyword));
};
const addColumn = (column: ColumnConfig) => {
setConfig('columns', (current) => [...current, column]);
const saveColumn = (column: ColumnType) => {
setConfig('columns', (current) => {
const index = current.findIndex((e) => e.id === column.id);
if (index >= 0) {
const newColumns = [...current];
newColumns.splice(index, 1, column);
return newColumns;
}
return [...current, column];
});
};
const moveColumn = (columnId: string, index: number) => {
setConfig('columns', (current) => {
// index starts with 1
const idx = index - 1;
const toIndex = Math.max(Math.min(idx, current.length), 0);
const fromIndex = current.findIndex((e) => e.id === columnId);
if (fromIndex < 0 || toIndex === fromIndex) return current;
console.log(fromIndex, toIndex);
const modified = [...current];
const [column] = modified.splice(fromIndex, 1);
modified.splice(toIndex, 0, column);
return modified;
});
};
const removeColumn = (columnId: string) => {
@@ -136,21 +152,18 @@ const useConfig = (): UseConfig => {
// すでに設定されている場合は終了
if ((config.columns?.length ?? 0) > 0) return;
const myColumns: ColumnConfig[] = [
{ id: generateId(), columnType: 'Following', title: 'ホーム', width: 'widest', pubkey },
{ id: generateId(), columnType: 'Notification', title: '通知', width: 'medium', pubkey },
{ id: generateId(), columnType: 'Posts', title: '自分の投稿', width: 'medium', pubkey },
{
id: generateId(),
columnType: 'Reactions',
title: '自分のリアクション',
width: 'medium',
pubkey,
},
// { columnType: 'Global', relays: [] },
const columns: ColumnType[] = [
createFollowingColumn({ width: 'widest', pubkey }),
createNotificationColumn({ pubkey }),
createPostsColumn({ name: '自分の投稿', pubkey }),
createReactionsColumn({ name: '自分のリアクション', pubkey }),
];
setConfig('columns', () => [...myColumns]);
if (navigator.language.includes('ja')) {
columns.splice(2, 0, createJapanRelaysColumn());
}
setConfig('columns', () => [...columns]);
};
return {
@@ -162,7 +175,8 @@ const useConfig = (): UseConfig => {
removeMutedPubkey,
addMutedKeyword,
removeMutedKeyword,
addColumn,
saveColumn,
moveColumn,
removeColumn,
isPubkeyMuted,
shouldMuteEvent,

View File

@@ -1,47 +0,0 @@
/**
* Content filter is a text which expresses the filter.
*
* - And condition
* - The text "AND" (upper case) or white space means an "and" condition (&&).
* - Examples:
* hello AND world
* hello world
* - Or condition
* - OR (upper case) means an "or" condition (&&).
* - Examples:
* hello OR world
* - Grouping
* ()
* - Normal text
* Case-insensitive
* helloworld,
* - Double quotes
* Case-sensitive
* "Hello World"
*/
export type ContentFilter = ContentFilterAnd | ContentFilterOr | ContentFilterNode;
type ContentFilterAnd = { type: 'AND'; children: ContentFilter[] };
type ContentFilterOr = { type: 'OR'; children: ContentFilter[] };
type ContentFilterNode = { text: string };
const applyContentFilter = (contentFilter: ContentFilter): boolean => {
// TODO implement
throw new Error('NotImplemented');
};
// NORMAL (implicit AND)
// A filter 'hello world' should match 'hello world'
// A filter "Lorem amet" should match "Lorem ipsum dolor sit amet"
//
// AND
//
// A filter "Lorem AND amet" should match "Lorem ipsum dolor sit amet"
//
// CASE
// A filter 'hello world' should match 'HELLO WORLD'
// A filter 'HELLO WORLD' should match 'hello world'
//
// DOUBLEQUOTE
// A filter '"HELLO WORLD"' should match 'HELLO WORLD'
// A filter '"HELLO WORLD"' should not match 'hello world'

View File

@@ -4,6 +4,8 @@ type ModalState =
| { type: 'Profile'; pubkey: string }
| { type: 'ProfileEdit' }
| { type: 'UserTimeline'; pubkey: string }
| { type: 'AddColumn' }
| { type: 'About' }
| { type: 'Closed' };
const [modalState, setModalState] = createSignal<ModalState>({ type: 'Closed' });
@@ -15,10 +17,24 @@ const useModalState = () => {
const showProfileEdit = () => {
setModalState({ type: 'ProfileEdit' });
};
const showAddColumn = () => {
setModalState({ type: 'AddColumn' });
};
const showAbout = () => {
setModalState({ type: 'About' });
};
const closeModal = () => {
setModalState({ type: 'Closed' });
};
return { modalState, setModalState, showProfile, showProfileEdit, closeModal };
return {
modalState,
setModalState,
showProfile,
showProfileEdit,
showAddColumn,
showAbout,
closeModal,
};
};
export default useModalState;

View File

@@ -2,8 +2,8 @@
import { Router } from '@solidjs/router';
import { render } from 'solid-js/web';
import './index.css';
import App from './App';
import '@/index.css';
import App from '@/App';
render(
() => (

View File

@@ -7,7 +7,7 @@ import parseTextNote, {
resolveTagReference,
type ParsedTextNoteNode,
TagReference,
} from './parseTextNote';
} from '@/nostr/parseTextNote';
describe('parseTextNote', () => {
/*

View File

@@ -1,6 +1,6 @@
import { nip19, type Event as NostrEvent } from 'nostr-tools';
import eventWrapper from './event';
import eventWrapper from '@/nostr/event';
type ProfilePointer = nip19.ProfilePointer;
type EventPointer = nip19.EventPointer;

View File

@@ -2,7 +2,7 @@ import assert from 'assert';
import { describe, it } from 'vitest';
import { buildTags } from './useCommands';
import { buildTags } from '@/nostr/useCommands';
describe('buildTags', () => {
it('should place a reply tag as first one if it is an only element', () => {

View File

@@ -3,6 +3,7 @@ import { createSignal, onMount, Switch, Match, type Component } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import usePersistStatus from '@/hooks/usePersistStatus';
import resolveAsset from '@/utils/resolveAsset';
type SignerStatus = 'checking' | 'available' | 'unavailable';
@@ -48,7 +49,7 @@ const Hello: Component = () => {
return (
<div class="mx-auto flex max-w-[640px] flex-col items-center p-4 text-stone-600">
<div class="flex flex-col items-center gap-4 rounded bg-white p-4">
<img src="./images/rabbit_256.png" width="96" alt="logo" height="96" />
<img src={resolveAsset('/images/rabbit_256.png')} width="96" alt="logo" height="96" />
<h1 class="text-5xl font-black text-rose-300">Rabbit</h1>
<div>Rabbit is a Web client for Nostr.</div>
<p class="text-center">

View File

@@ -1,34 +1,24 @@
import { createEffect, onMount, Show, Switch, Match, type Component } from 'solid-js';
import { createEffect, onMount, type Component } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { createVirtualizer } from '@tanstack/solid-virtual';
import uniq from 'lodash/uniq';
import Column from '@/components/Column';
import ProfileDisplay from '@/components/modal/ProfileDisplay';
import ProfileEdit from '@/components/modal/ProfileEdit';
import Notification from '@/components/Notification';
import Columns from '@/components/column/Columns';
import GlobalModal from '@/components/modal/GlobalModal';
import SideBar from '@/components/SideBar';
import Timeline from '@/components/Timeline';
import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState';
import usePersistStatus from '@/hooks/usePersistStatus';
import { useMountShortcutKeys } from '@/hooks/useShortcutKeys';
import useFollowings from '@/nostr/useFollowings';
import usePool from '@/nostr/usePool';
import usePubkey from '@/nostr/usePubkey';
import useSubscription from '@/nostr/useSubscription';
import ensureNonNull from '@/utils/ensureNonNull';
import epoch from '@/utils/epoch';
const Home: Component = () => {
useMountShortcutKeys();
const navigate = useNavigate();
const { persistStatus } = usePersistStatus();
const { modalState, showProfile, closeModal } = useModalState();
const pool = usePool();
const { config } = useConfig();
const { config, initializeColumns } = useConfig();
const pubkey = usePubkey();
createEffect(() => {
@@ -40,86 +30,14 @@ const Home: Component = () => {
});
});
const { followingPubkeys } = useFollowings(() =>
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
relayUrls: config().relayUrls,
pubkey: pubkeyNonNull,
})),
);
const { events: followingsPosts } = useSubscription(() => {
const authors = uniq([...followingPubkeys()]);
if (authors.length === 0) return null;
return {
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6],
authors,
limit: 10,
since: epoch() - 4 * 60 * 60,
},
],
};
createEffect(() => {
// pubkeyが得られてはじめてカラムを初期化できる
const p = pubkey();
if (p != null) {
initializeColumns({ pubkey: p });
}
});
const { events: myPosts } = useSubscription(() =>
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6],
authors: [pubkeyNonNull],
limit: 10,
},
],
})),
);
const { events: myReactions } = useSubscription(() =>
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [7],
authors: [pubkeyNonNull],
limit: 10,
},
],
})),
);
const { events: notifications } = useSubscription(() =>
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6, 7],
'#p': [pubkeyNonNull],
limit: 10,
},
],
})),
);
const { events: localTimeline } = useSubscription(() => ({
relayUrls: [
'wss://relay-jp.nostr.wirednet.jp',
'wss://nostr.h3z.jp',
'wss://nostr.holybea.com',
],
filters: [
{
kinds: [1, 6],
limit: 25,
since: epoch() - 4 * 60 * 60,
},
],
clientEventFilter: (ev) => {
return /[\p{scx=Hiragana}\p{scx=Katakana}\p{sc=Han}]/u.test(ev.content);
},
}));
onMount(() => {
if (!persistStatus().loggedIn) {
navigate('/hello');
@@ -129,43 +47,8 @@ const Home: Component = () => {
return (
<div class="absolute inset-0 flex w-screen touch-manipulation flex-row overflow-hidden">
<SideBar />
<div class="flex h-full snap-x snap-mandatory flex-row overflow-y-hidden overflow-x-scroll">
<Column name="ホーム" columnIndex={1} width="widest">
<Timeline events={followingsPosts()} />
</Column>
<Column name="通知" columnIndex={2} width="medium">
<Notification events={notifications()} />
</Column>
<Column name="日本リレー" columnIndex={3} width="medium">
<Timeline events={localTimeline()} />
</Column>
<Column name="自分の投稿" columnIndex={4} width="medium">
<Timeline events={myPosts()} />
</Column>
<Column name="自分のいいね" columnIndex={5} lastColumn width="medium">
<Notification events={myReactions()} />
</Column>
</div>
<Show when={modalState()} keyed>
{(state) => (
<Switch>
<Match when={state.type === 'Profile' && state.pubkey} keyed>
{(pubkeyNonNull: string) => (
<ProfileDisplay pubkey={pubkeyNonNull} onClose={closeModal} />
)}
</Match>
<Match when={state.type === 'ProfileEdit'} keyed>
<ProfileEdit
onClose={() =>
ensureNonNull([pubkey()])(([pubkeyNonNull]) => {
showProfile(pubkeyNonNull);
})
}
/>
</Match>
</Switch>
)}
</Show>
<Columns />
<GlobalModal />
</div>
);
};

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import nextId from './nextId';
import nextId from '@/utils/batch/nextId';
export default class ObservableTask<BatchRequest, BatchResponse> {
id: number;

View File

@@ -2,7 +2,7 @@ import assert from 'assert';
import { describe, it } from 'vitest';
import { fixUrl } from './imageUrl';
import { fixUrl } from '@/utils/imageUrl';
describe('fixUrl', () => {
it('should return an image url for a given imgur.com URL with additional path', () => {

View File

@@ -0,0 +1,6 @@
const resolveAsset = (path: string): string => {
const baseUrl = new URL(import.meta.env.BASE_URL, window.location.href);
return new URL(path, baseUrl).href;
};
export default resolveAsset;