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 }, ecmaFeatures: { jsx: true },
ecmaVersion: 2021, 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: { rules: {
'no-alert': ['off'], 'no-alert': ['off'],
'no-console': ['off'], 'no-console': ['off'],
'no-relative-import-paths/no-relative-import-paths': ['error', { rootDir: 'src', prefix: '@' }],
'import/extensions': [ 'import/extensions': [
'error', 'error',
'ignorePackages', 'ignorePackages',

13
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"eslint-import-resolver-typescript": "^3.5.5", "eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-no-relative-import-paths": "^1.5.2",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-solid": "^0.12.1", "eslint-plugin-solid": "^0.12.1",
"eslint-plugin-tailwindcss": "^3.11.0", "eslint-plugin-tailwindcss": "^3.11.0",
@@ -3174,6 +3175,12 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" "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": { "node_modules/eslint-plugin-prettier": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz",
@@ -9621,6 +9628,12 @@
"semver": "^6.3.0" "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": { "eslint-plugin-prettier": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", "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-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-no-relative-import-paths": "^1.5.2",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-solid": "^0.12.1", "eslint-plugin-solid": "^0.12.1",
"eslint-plugin-tailwindcss": "^3.11.0", "eslint-plugin-tailwindcss": "^3.11.0",

View File

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

View File

@@ -2,7 +2,8 @@ import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import util from 'util'; 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) => { const getPackageInfo = async (key) => {
try { 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 { Event as NostrEvent } from 'nostr-tools';
import ColumnItem from '@/components/ColumnItem'; import ColumnItem from '@/components/ColumnItem';
import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById';
import UserDisplayName from '@/components/UserDisplayName'; import UserDisplayName from '@/components/UserDisplayName';
import useFormatDate from '@/hooks/useFormatDate'; import useFormatDate from '@/hooks/useFormatDate';
import useModalState from '@/hooks/useModalState'; import useModalState from '@/hooks/useModalState';
import eventWrapper from '@/nostr/event'; import eventWrapper from '@/nostr/event';
import TextNoteDisplayById from './textNote/TextNoteDisplayById';
export type DeprecatedRepostProps = { export type DeprecatedRepostProps = {
event: NostrEvent; 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 */ /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div <div
ref={containerRef} 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} onClick={handleClickContainer}
> >
{props.children} {props.children}

View File

@@ -1,6 +1,7 @@
import { createSignal, Show, type JSX, Component } from 'solid-js'; import { createSignal, Show, type JSX, Component } from 'solid-js';
import Cog6Tooth from 'heroicons/24/outline/cog-6-tooth.svg'; 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 MagnifyingGlass from 'heroicons/24/solid/magnifying-glass.svg';
import PencilSquare from 'heroicons/24/solid/pencil-square.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 NotePostForm from '@/components/NotePostForm';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import { useHandleCommand } from '@/hooks/useCommandBus'; import { useHandleCommand } from '@/hooks/useCommandBus';
import useModalState from '@/hooks/useModalState';
import resolveAsset from '@/utils/resolveAsset';
const SideBar: Component = () => { const SideBar: Component = () => {
let textAreaRef: HTMLTextAreaElement | undefined; let textAreaRef: HTMLTextAreaElement | undefined;
const { showAddColumn, showAbout } = useModalState();
const { config } = useConfig(); const { config } = useConfig();
const [formOpened, setFormOpened] = createSignal(false); const [formOpened, setFormOpened] = createSignal(false);
const [configOpened, setConfigOpened] = 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 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"> <div class="flex flex-col items-center gap-3">
<button <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()} onClick={() => toggleForm()}
> >
<PencilSquare /> <PencilSquare />
@@ -49,17 +53,28 @@ const SideBar: Component = () => {
<MagnifyingGlass /> <MagnifyingGlass />
</button> </button>
*/} */}
{/* <div>column 1</div> */}
{/* <div>column 2</div> */}
</div> </div>
<div class="grow" /> <div class="grow" />
<div> <div class="flex flex-col items-center pb-2">
<button <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)} onClick={() => setConfigOpened((current) => !current)}
> >
<Cog6Tooth /> <Cog6Tooth />
</button> </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> </div>
<div <div

View File

@@ -1,10 +1,9 @@
import { Show, type Component } from 'solid-js'; import { Show, type Component } from 'solid-js';
import ColumnItem from '@/components/ColumnItem'; import ColumnItem from '@/components/ColumnItem';
import TextNoteDisplay, { TextNoteDisplayProps } from '@/components/textNote/TextNoteDisplay';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import TextNoteDisplay, { TextNoteDisplayProps } from './textNote/TextNoteDisplay';
export type TextNoteProps = TextNoteDisplayProps; export type TextNoteProps = TextNoteDisplayProps;
const TextNote: Component<TextNoteProps> = (props) => { 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 ArrowLeft from 'heroicons/24/outline/arrow-left.svg';
import TimelineContentDisplay from '@/components/TimelineContentDisplay'; import TimelineContentDisplay from '@/components/timeline/TimelineContentDisplay';
import { TimelineContext, useTimelineState } from '@/components/TimelineContext'; import { TimelineContext, useTimelineState } from '@/components/timeline/TimelineContext';
import { useHandleCommand } from '@/hooks/useCommandBus'; import { useHandleCommand } from '@/hooks/useCommandBus';
export type ColumnProps = { export type ColumnProps = {
name: string;
columnIndex: number; columnIndex: number;
lastColumn?: true; lastColumn: boolean;
width: 'widest' | 'wide' | 'medium' | 'narrow' | null | undefined; width: 'widest' | 'wide' | 'medium' | 'narrow' | null | undefined;
header: JSX.Element;
children: 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" class="flex w-[80vw] shrink-0 snap-center snap-always flex-col border-r sm:snap-align-none"
classList={{ classList={{
'sm:w-[500px]': width() === 'widest', 'sm:w-[500px]': width() === 'widest',
'sm:w-[350px]': width() === 'wide', 'sm:w-[360px]': width() === 'wide',
'sm:w-[310px]': width() === 'medium', 'sm:w-[320px]': width() === 'medium',
'sm:w-[270px]': width() === 'narrow', 'sm:w-[280px]': width() === 'narrow',
}} }}
> >
<Show <Show
@@ -56,17 +56,14 @@ const Column: Component<ColumnProps> = (props) => {
keyed keyed
fallback={ fallback={
<> <>
<div class="flex h-8 shrink-0 items-center border-b bg-white px-2"> <div class="shrink-0 border-b">{props.header}</div>
{/* <span class="column-icon">🏠</span> */}
<span class="column-name">{props.name}</span>
</div>
<div class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</div> <div class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</div>
</> </>
} }
> >
{(timeline) => ( {(timeline) => (
<div class="h-full w-full bg-white"> <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 <button
class="flex w-full items-center gap-1" class="flex w-full items-center gap-1"
onClick={() => timelineState?.clearTimeline()} 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 { Component } from 'solid-js';
import useConfig from '@/core/useConfig';
const AddColumn = () => { import Bell from 'heroicons/24/outline/bell.svg';
const { addColumn } = useConfig(); 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 ( return (
<BasicModal onClose={() => console.log('closed')}> <BasicModal onClose={props.onClose}>
<ul> <div class="flex flex-wrap p-4">
<li></li> <button
<li></li> class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
<li></li> onClick={() => addFollowingColumn()}
<li></li> >
<li></li> <span class="inline-block h-8 w-8">
<li></li> <Home />
<li></li> </span>
</ul>
</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> </BasicModal>
); );
}; };

View File

@@ -13,7 +13,7 @@ export type BasicModalProps = {
const BasicModal: Component<BasicModalProps> = (props) => { const BasicModal: Component<BasicModalProps> = (props) => {
return ( return (
<Modal onClose={() => props.onClose?.()}> <Modal onClose={() => props.onClose?.()}>
<div class="h-screen w-[640px] max-w-full"> <div class="h-full w-[640px] max-w-full">
<button <button
class="w-full pt-1 text-start text-stone-800" class="w-full pt-1 text-start text-stone-800"
aria-label="Close" 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 ContextMenu, { MenuItem } from '@/components/ContextMenu';
import BasicModal from '@/components/modal/BasicModal'; import BasicModal from '@/components/modal/BasicModal';
import Timeline from '@/components/Timeline'; import Timeline from '@/components/timeline/Timeline';
import SafeLink from '@/components/utils/SafeLink'; import SafeLink from '@/components/utils/SafeLink';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState'; import useModalState from '@/hooks/useModalState';
@@ -236,7 +236,7 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
<Match when={following()}> <Match when={following()}>
<button <button
class="rounded-full border border-primary bg-primary px-4 py-2 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)} onMouseEnter={() => setHoverFollowButton(true)}
onMouseLeave={() => setHoverFollowButton(false)} onMouseLeave={() => setHoverFollowButton(false)}
onClick={() => unfollow()} onClick={() => unfollow()}
@@ -249,7 +249,7 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
</Match> </Match>
<Match when={!following()}> <Match when={!following()}>
<button <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" hover:border-rose-400 hover:text-rose-400"
onClick={() => follow()} onClick={() => follow()}
disabled={updateContactsMutation.isLoading} disabled={updateContactsMutation.isLoading}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
// import { z } from 'zod'; // import { z } from 'zod';
import { type Filter } from 'nostr-tools'; 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 = export type NotificationType =
// The event which includes ["p", ...] tags. // The event which includes ["p", ...] tags.
@@ -44,51 +47,120 @@ type BulidOptions = {
export type BaseColumn = { export type BaseColumn = {
id: string; id: string;
title: string; name?: string;
width: ColumnProps['width']; width: ColumnProps['width'];
contentFilter?: ContentFilter;
}; };
/** A column which shows posts by following users */ /** A column which shows posts by following users */
export type FollowingColumn = BaseColumn & { export type FollowingColumnType = BaseColumn & {
columnType: 'Following'; columnType: 'Following';
pubkey: string; pubkey: string;
}; };
/** A column which shows replies, reactions, reposts to the specific user */ /** A column which shows replies, reactions, reposts to the specific user */
export type NotificationColumn = BaseColumn & { export type NotificationColumnType = BaseColumn & {
columnType: 'Notification'; columnType: 'Notification';
// notificationTypes: NotificationType[]; // notificationTypes: NotificationType[];
pubkey: string; pubkey: string;
}; };
/** A column which shows posts from the specific user */ /** A column which shows posts from the specific user */
export type PostsColumn = BaseColumn & { export type PostsColumnType = BaseColumn & {
columnType: 'Posts'; columnType: 'Posts';
pubkey: string; pubkey: string;
}; };
/** A column which shows reactions published by the specific user */ /** A column which shows reactions published by the specific user */
export type ReactionsColumn = BaseColumn & { export type ReactionsColumnType = BaseColumn & {
columnType: 'Reactions'; columnType: 'Reactions';
pubkey: string; pubkey: string;
}; };
/** A column which shows text notes and reposts posted to the specific relays */ /** A column which shows text notes and reposts posted to the specific relays */
export type GlobalColumn = BaseColumn & { export type RelaysColumnType = BaseColumn & {
columnType: 'Global'; columnType: 'Relays';
relayUrls: string[]; 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 */ /** A column which shows text notes and reposts posted to the specific relays */
export type CustomFilterColumn = BaseColumn & { export type CustomFilterColumnType = BaseColumn & {
columnType: 'CustomFilter'; columnType: 'CustomFilter';
filters: Filter[]; filters: Filter[];
}; };
export type ColumnConfig = export type ColumnType =
| FollowingColumn | FollowingColumnType
| NotificationColumn | NotificationColumnType
| GlobalColumn | RelaysColumnType
| PostsColumn | PostsColumnType
| ReactionsColumn | ReactionsColumnType
| CustomFilterColumn; | 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 uniq from 'lodash/uniq';
import { Kind, type Event as NostrEvent } from 'nostr-tools'; 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 { import {
createStorageWithSerializer, createStorageWithSerializer,
createStoreWithStorage, createStoreWithStorage,
} from '@/hooks/createSignalWithStorage'; } from '@/hooks/createSignalWithStorage';
import generateId from '@/utils/generateId';
export type Config = { export type Config = {
relayUrls: string[]; relayUrls: string[];
columns: ColumnConfig[]; columns: ColumnType[];
dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; dateFormat: 'relative' | 'absolute-long' | 'absolute-short';
keepOpenPostForm: boolean; keepOpenPostForm: boolean;
showImage: boolean; showImage: boolean;
@@ -24,44 +31,29 @@ export type Config = {
type UseConfig = { type UseConfig = {
config: Accessor<Config>; config: Accessor<Config>;
setConfig: Setter<Config>; setConfig: Setter<Config>;
// relay
addRelay: (url: string) => void; addRelay: (url: string) => void;
removeRelay: (url: string) => void; removeRelay: (url: string) => void;
// mute
addMutedPubkey: (pubkey: string) => void; addMutedPubkey: (pubkey: string) => void;
removeMutedPubkey: (pubkey: string) => void; removeMutedPubkey: (pubkey: string) => void;
addMutedKeyword: (keyword: string) => void; addMutedKeyword: (keyword: string) => void;
removeMutedKeyword: (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; removeColumn: (columnId: string) => void;
// functions
isPubkeyMuted: (pubkey: string) => boolean; isPubkeyMuted: (pubkey: string) => boolean;
shouldMuteEvent: (event: NostrEvent) => boolean; shouldMuteEvent: (event: NostrEvent) => boolean;
initializeColumns: (param: { pubkey: string }) => void; 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 initialRelays = (): string[] => {
const relayUrls = [...relaysGlobal]; const relayUrls = [...relaysGlobal];
if (navigator.language === 'ja') { if (window.navigator.language.includes('ja')) {
relayUrls.push(...relaysInJP); relayUrls.push(...relaysInJP);
} }
return relayUrls; return relayUrls;
}; };
@@ -112,8 +104,32 @@ const useConfig = (): UseConfig => {
setConfig('mutedKeywords', (current) => current.filter((e) => e !== keyword)); setConfig('mutedKeywords', (current) => current.filter((e) => e !== keyword));
}; };
const addColumn = (column: ColumnConfig) => { const saveColumn = (column: ColumnType) => {
setConfig('columns', (current) => [...current, column]); 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) => { const removeColumn = (columnId: string) => {
@@ -136,21 +152,18 @@ const useConfig = (): UseConfig => {
// すでに設定されている場合は終了 // すでに設定されている場合は終了
if ((config.columns?.length ?? 0) > 0) return; if ((config.columns?.length ?? 0) > 0) return;
const myColumns: ColumnConfig[] = [ const columns: ColumnType[] = [
{ id: generateId(), columnType: 'Following', title: 'ホーム', width: 'widest', pubkey }, createFollowingColumn({ width: 'widest', pubkey }),
{ id: generateId(), columnType: 'Notification', title: '通知', width: 'medium', pubkey }, createNotificationColumn({ pubkey }),
{ id: generateId(), columnType: 'Posts', title: '自分の投稿', width: 'medium', pubkey }, createPostsColumn({ name: '自分の投稿', pubkey }),
{ createReactionsColumn({ name: '自分のリアクション', pubkey }),
id: generateId(),
columnType: 'Reactions',
title: '自分のリアクション',
width: 'medium',
pubkey,
},
// { columnType: 'Global', relays: [] },
]; ];
setConfig('columns', () => [...myColumns]); if (navigator.language.includes('ja')) {
columns.splice(2, 0, createJapanRelaysColumn());
}
setConfig('columns', () => [...columns]);
}; };
return { return {
@@ -162,7 +175,8 @@ const useConfig = (): UseConfig => {
removeMutedPubkey, removeMutedPubkey,
addMutedKeyword, addMutedKeyword,
removeMutedKeyword, removeMutedKeyword,
addColumn, saveColumn,
moveColumn,
removeColumn, removeColumn,
isPubkeyMuted, isPubkeyMuted,
shouldMuteEvent, 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: 'Profile'; pubkey: string }
| { type: 'ProfileEdit' } | { type: 'ProfileEdit' }
| { type: 'UserTimeline'; pubkey: string } | { type: 'UserTimeline'; pubkey: string }
| { type: 'AddColumn' }
| { type: 'About' }
| { type: 'Closed' }; | { type: 'Closed' };
const [modalState, setModalState] = createSignal<ModalState>({ type: 'Closed' }); const [modalState, setModalState] = createSignal<ModalState>({ type: 'Closed' });
@@ -15,10 +17,24 @@ const useModalState = () => {
const showProfileEdit = () => { const showProfileEdit = () => {
setModalState({ type: 'ProfileEdit' }); setModalState({ type: 'ProfileEdit' });
}; };
const showAddColumn = () => {
setModalState({ type: 'AddColumn' });
};
const showAbout = () => {
setModalState({ type: 'About' });
};
const closeModal = () => { const closeModal = () => {
setModalState({ type: 'Closed' }); setModalState({ type: 'Closed' });
}; };
return { modalState, setModalState, showProfile, showProfileEdit, closeModal }; return {
modalState,
setModalState,
showProfile,
showProfileEdit,
showAddColumn,
showAbout,
closeModal,
};
}; };
export default useModalState; export default useModalState;

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import assert from 'assert';
import { describe, it } from 'vitest'; import { describe, it } from 'vitest';
import { buildTags } from './useCommands'; import { buildTags } from '@/nostr/useCommands';
describe('buildTags', () => { describe('buildTags', () => {
it('should place a reply tag as first one if it is an only element', () => { 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 { useNavigate } from '@solidjs/router';
import usePersistStatus from '@/hooks/usePersistStatus'; import usePersistStatus from '@/hooks/usePersistStatus';
import resolveAsset from '@/utils/resolveAsset';
type SignerStatus = 'checking' | 'available' | 'unavailable'; type SignerStatus = 'checking' | 'available' | 'unavailable';
@@ -48,7 +49,7 @@ const Hello: Component = () => {
return ( return (
<div class="mx-auto flex max-w-[640px] flex-col items-center p-4 text-stone-600"> <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"> <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> <h1 class="text-5xl font-black text-rose-300">Rabbit</h1>
<div>Rabbit is a Web client for Nostr.</div> <div>Rabbit is a Web client for Nostr.</div>
<p class="text-center"> <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 { useNavigate } from '@solidjs/router';
import { createVirtualizer } from '@tanstack/solid-virtual';
import uniq from 'lodash/uniq';
import Column from '@/components/Column'; import Columns from '@/components/column/Columns';
import ProfileDisplay from '@/components/modal/ProfileDisplay'; import GlobalModal from '@/components/modal/GlobalModal';
import ProfileEdit from '@/components/modal/ProfileEdit';
import Notification from '@/components/Notification';
import SideBar from '@/components/SideBar'; import SideBar from '@/components/SideBar';
import Timeline from '@/components/Timeline';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import useModalState from '@/hooks/useModalState';
import usePersistStatus from '@/hooks/usePersistStatus'; import usePersistStatus from '@/hooks/usePersistStatus';
import { useMountShortcutKeys } from '@/hooks/useShortcutKeys'; import { useMountShortcutKeys } from '@/hooks/useShortcutKeys';
import useFollowings from '@/nostr/useFollowings';
import usePool from '@/nostr/usePool'; import usePool from '@/nostr/usePool';
import usePubkey from '@/nostr/usePubkey'; import usePubkey from '@/nostr/usePubkey';
import useSubscription from '@/nostr/useSubscription';
import ensureNonNull from '@/utils/ensureNonNull';
import epoch from '@/utils/epoch';
const Home: Component = () => { const Home: Component = () => {
useMountShortcutKeys(); useMountShortcutKeys();
const navigate = useNavigate(); const navigate = useNavigate();
const { persistStatus } = usePersistStatus(); const { persistStatus } = usePersistStatus();
const { modalState, showProfile, closeModal } = useModalState();
const pool = usePool(); const pool = usePool();
const { config } = useConfig(); const { config, initializeColumns } = useConfig();
const pubkey = usePubkey(); const pubkey = usePubkey();
createEffect(() => { createEffect(() => {
@@ -40,86 +30,14 @@ const Home: Component = () => {
}); });
}); });
const { followingPubkeys } = useFollowings(() => createEffect(() => {
ensureNonNull([pubkey()] as const)(([pubkeyNonNull]) => ({ // pubkeyが得られてはじめてカラムを初期化できる
relayUrls: config().relayUrls, const p = pubkey();
pubkey: pubkeyNonNull, if (p != null) {
})), initializeColumns({ pubkey: p });
); }
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,
},
],
};
}); });
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(() => { onMount(() => {
if (!persistStatus().loggedIn) { if (!persistStatus().loggedIn) {
navigate('/hello'); navigate('/hello');
@@ -129,43 +47,8 @@ const Home: Component = () => {
return ( return (
<div class="absolute inset-0 flex w-screen touch-manipulation flex-row overflow-hidden"> <div class="absolute inset-0 flex w-screen touch-manipulation flex-row overflow-hidden">
<SideBar /> <SideBar />
<div class="flex h-full snap-x snap-mandatory flex-row overflow-y-hidden overflow-x-scroll"> <Columns />
<Column name="ホーム" columnIndex={1} width="widest"> <GlobalModal />
<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>
</div> </div>
); );
}; };

View File

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

View File

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