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 },
|
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
13
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
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 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()}
|
||||||
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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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 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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
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 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,
|
||||||
|
|||||||
@@ -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: '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;
|
||||||
|
|||||||
@@ -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(
|
||||||
() => (
|
() => (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import parseTextNote, {
|
|||||||
resolveTagReference,
|
resolveTagReference,
|
||||||
type ParsedTextNoteNode,
|
type ParsedTextNoteNode,
|
||||||
TagReference,
|
TagReference,
|
||||||
} from './parseTextNote';
|
} from '@/nostr/parseTextNote';
|
||||||
|
|
||||||
describe('parseTextNote', () => {
|
describe('parseTextNote', () => {
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
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