mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 05:54:19 +01:00
feat: column customization
This commit is contained in:
11
.eslintrc.js
11
.eslintrc.js
@@ -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
13
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' })
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
35
src/components/column/BasicColumnHeader.tsx
Normal file
35
src/components/column/BasicColumnHeader.tsx
Normal 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;
|
||||
@@ -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()}
|
||||
101
src/components/column/ColumnSettings.tsx
Normal file
101
src/components/column/ColumnSettings.tsx
Normal 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;
|
||||
74
src/components/column/Columns.tsx
Normal file
74
src/components/column/Columns.tsx
Normal 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;
|
||||
67
src/components/column/FollwingColumn.tsx
Normal file
67
src/components/column/FollwingColumn.tsx
Normal 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;
|
||||
57
src/components/column/NotificationColumn.tsx
Normal file
57
src/components/column/NotificationColumn.tsx
Normal 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;
|
||||
57
src/components/column/PostsColumn.tsx
Normal file
57
src/components/column/PostsColumn.tsx
Normal 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;
|
||||
57
src/components/column/ReactionsColumn.tsx
Normal file
57
src/components/column/ReactionsColumn.tsx
Normal 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;
|
||||
58
src/components/column/RelaysColumn.tsx
Normal file
58
src/components/column/RelaysColumn.tsx
Normal 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;
|
||||
58
src/components/column/SearchColumn.tsx
Normal file
58
src/components/column/SearchColumn.tsx
Normal 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;
|
||||
104
src/components/modal/About.tsx
Normal file
104
src/components/modal/About.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
45
src/components/modal/GlobalModal.tsx
Normal file
45
src/components/modal/GlobalModal.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
19
src/core/contentFilter.test.ts
Normal file
19
src/core/contentFilter.test.ts
Normal 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
50
src/core/contentFilter.ts
Normal 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
23
src/core/relayUrls.ts
Normal 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',
|
||||
];
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
() => (
|
||||
|
||||
@@ -7,7 +7,7 @@ import parseTextNote, {
|
||||
resolveTagReference,
|
||||
type ParsedTextNoteNode,
|
||||
TagReference,
|
||||
} from './parseTextNote';
|
||||
} from '@/nostr/parseTextNote';
|
||||
|
||||
describe('parseTextNote', () => {
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
6
src/utils/resolveAsset.tsx
Normal file
6
src/utils/resolveAsset.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user