This commit is contained in:
Shusui MOYATANI
2023-06-06 19:40:50 +09:00
parent fd80c92b83
commit 188475427b
14 changed files with 331 additions and 131 deletions

92
src/components/Post.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { Component, JSX, Show, createSignal } from 'solid-js';
import useDetectOverflow from '@/hooks/useDetectOverflow';
import useFormatDate from '@/hooks/useFormatDate';
export type PostProps = {
author: JSX.Element;
createdAt: Date;
content: JSX.Element;
actions?: JSX.Element;
footer?: JSX.Element;
authorPictureUrl?: string;
onShowProfile?: () => void;
onShowEvent?: () => void;
};
const Post: Component<PostProps> = (props) => {
const { overflow, elementRef } = useDetectOverflow();
const formatDate = useFormatDate();
const [showOverflow, setShowOverflow] = createSignal();
const createdAt = () => formatDate(props.createdAt);
return (
<div class="post flex flex-col">
<div class="flex w-full gap-1">
<button
type="button"
class="author-icon h-10 w-10 shrink-0 overflow-hidden"
onClick={(ev) => {
ev.preventDefault();
props.onShowProfile?.();
}}
>
<Show when={props.authorPictureUrl} keyed>
{(url) => <img src={url} alt="icon" class="h-full w-full rounded object-cover" />}
</Show>
</button>
<div class="min-w-0 flex-auto">
<div class="flex justify-between gap-1 text-xs">
<button
type="button"
class="author flex min-w-0 truncate hover:text-blue-500"
onClick={(ev) => {
ev.preventDefault();
props?.onShowProfile?.();
}}
>
{props.author}
</button>
<div class="created-at shrink-0">
<button
type="button"
class="hover:underline"
onClick={(ev) => {
ev.preventDefault();
props.onShowEvent?.();
}}
>
{createdAt()}
</button>
</div>
</div>
<div
ref={elementRef}
class="overflow-hidden"
classList={{ 'max-h-screen': !showOverflow() }}
>
{props.content}
</div>
<Show when={overflow()}>
<button
class="mt-2 w-full rounded border p-2 text-center text-xs text-stone-600 shadow-sm hover:shadow"
onClick={(ev) => {
ev.stopPropagation();
setShowOverflow((current) => !current);
}}
>
<Show when={!showOverflow()} fallback="隠す">
</Show>
</button>
</Show>
<div class="actions">{props.actions}</div>
</div>
</div>
<Show when={props.footer}>{props.footer}</Show>
</div>
);
};
export default Post;

View File

@@ -0,0 +1,62 @@
import { Component, createEffect, onCleanup, onMount } from 'solid-js';
import ChatBubbleLeftRight from 'heroicons/24/outline/chat-bubble-left-right.svg';
import { uniq } from 'lodash';
import { Kind } from 'nostr-tools';
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 { ChannelColumnType, FollowingColumnType } from '@/core/column';
import { applyContentFilter } from '@/core/contentFilter';
import useConfig from '@/core/useConfig';
import useFollowings from '@/nostr/useFollowings';
import useSubscription from '@/nostr/useSubscription';
import epoch from '@/utils/epoch';
export type ChannelColumnProps = {
columnIndex: number;
lastColumn: boolean;
column: ChannelColumnType;
};
const ChannelColumn: Component<ChannelColumnProps> = (props) => {
const { config, removeColumn } = useConfig();
const { events } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [Kind.ChannelMessage],
limit: 10,
'#e': [props.column.id],
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={<ChatBubbleLeftRight />}
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 ChannelColumn;

View File

View File

@@ -12,6 +12,8 @@ export type ChannelInfoProps = {
const ChannelInfo: Component<ChannelInfoProps> = (props) => {
const parsedContent = () => parseChannelMeta(props.event.content);
// useChannelMeta
return (
<Show when={parsedContent()} keyed>
{(meta) => (

View File

@@ -1,4 +1,4 @@
import { Show, For, createSignal, createMemo, onMount, type JSX, type Component } from 'solid-js';
import { Show, For, createSignal, createMemo, type JSX, type Component } from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
@@ -17,9 +17,9 @@ import ContentWarningDisplay from '@/components/event/textNote/ContentWarningDis
import GeneralUserMentionDisplay from '@/components/event/textNote/GeneralUserMentionDisplay';
import TextNoteContentDisplay from '@/components/event/textNote/TextNoteContentDisplay';
import NotePostForm from '@/components/NotePostForm';
import Post from '@/components/Post';
import { useTimelineContext } from '@/components/timeline/TimelineContext';
import useConfig from '@/core/useConfig';
import useFormatDate from '@/hooks/useFormatDate';
import useModalState from '@/hooks/useModalState';
import { textNote } from '@/nostr/event';
import useCommands from '@/nostr/useCommands';
@@ -85,10 +85,7 @@ const EmojiReactions: Component<EmojiReactionsProps> = (props) => {
};
const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
let contentRef: HTMLDivElement | undefined;
const { config } = useConfig();
const formatDate = useFormatDate();
const pubkey = usePubkey();
const { showProfile } = useModalState();
const timelineContext = useTimelineContext();
@@ -97,8 +94,6 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const [reposted, setReposted] = createSignal(false);
const [showReplyForm, setShowReplyForm] = createSignal(false);
const closeReplyForm = () => setShowReplyForm(false);
const [showOverflow, setShowOverflow] = createSignal(false);
const [overflow, setOverflow] = createSignal(false);
const event = createMemo(() => textNote(props.event));
@@ -252,8 +247,6 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
return undefined;
};
const createdAt = () => formatDate(event().createdAtAsDate());
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
@@ -296,72 +289,31 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
doReaction();
};
onMount(() => {
if (contentRef != null) {
setOverflow(contentRef.scrollHeight > contentRef.clientHeight);
}
});
return (
<div class="nostr-textnote flex flex-col">
<div class="flex w-full gap-1">
<button
class="author-icon h-10 w-10 shrink-0 overflow-hidden"
onClick={(ev) => {
ev.stopPropagation();
showProfile(event().pubkey);
}}
>
<Show when={author()?.picture}>
<img src={author()?.picture} alt="icon" class="h-full w-full rounded object-cover" />
</Show>
</button>
<div class="min-w-0 flex-auto">
<div class="flex justify-between gap-1 text-xs">
<button
class="author flex min-w-0 truncate hover:text-blue-500"
onClick={(ev) => {
ev.stopPropagation();
showProfile(event().pubkey);
}}
>
<Show when={(author()?.display_name?.length ?? 0) > 0}>
<div class="author-name truncate pr-1 font-bold hover:underline">
{author()?.display_name}
</div>
</Show>
<div class="author-username truncate text-zinc-600">
<Show
when={author()?.name != null}
fallback={`@${npubEncodeFallback(event().pubkey)}`}
>
@{author()?.name}
</Show>
{/* TODO <Match when={author()?.nip05 != null}>@{author()?.nip05}</Match> */}
<div class="nostr-textnote">
<Post
author={
<span class="author flex min-w-0 truncate hover:text-blue-500">
<Show when={(author()?.display_name?.length ?? 0) > 0}>
<div class="author-name truncate pr-1 font-bold hover:underline">
{author()?.display_name}
</div>
</button>
<div class="created-at shrink-0">
<a
href={`nostr:${noteEncode(event().id)}`}
type="button"
class="hover:underline"
onClick={(ev) => {
ev.preventDefault();
timelineContext?.setTimeline({
type: 'Replies',
event: props.event,
});
}}
</Show>
<div class="author-username truncate text-zinc-600">
<Show
when={author()?.name != null}
fallback={`@${npubEncodeFallback(event().pubkey)}`}
>
{createdAt()}
</a>
@{author()?.name}
</Show>
{/* TODO <Match when={author()?.nip05 != null}>@{author()?.nip05}</Match> */}
</div>
</div>
<div
ref={contentRef}
class="overflow-hidden"
classList={{ 'max-h-screen': !showOverflow() }}
>
</span>
}
authorPictureUrl={author()?.picture}
createdAt={event().createdAtAsDate()}
content={
<div class="textnote-content">
<Show when={showReplyEvent()} keyed>
{(id) => (
<div class="mt-1 rounded border p-1">
@@ -393,19 +345,8 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
</div>
</ContentWarningDisplay>
</div>
<Show when={overflow()}>
<button
class="mt-2 w-full rounded border p-2 text-center text-xs text-stone-600 shadow-sm hover:shadow"
onClick={(ev) => {
ev.stopPropagation();
setShowOverflow((current) => !current);
}}
>
<Show when={!showOverflow()} fallback="隠す">
</Show>
</button>
</Show>
}
actions={
<Show when={actions()}>
<Show when={config().showEmojiReaction && reactions().length > 0}>
<EmojiReactions
@@ -499,16 +440,24 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
</div>
</div>
</Show>
</div>
</div>
<Show when={showReplyForm()}>
<NotePostForm
mode="reply"
replyTo={props.event}
onClose={closeReplyForm}
onPost={closeReplyForm}
/>
</Show>
}
footer={
<Show when={showReplyForm()}>
<NotePostForm
mode="reply"
replyTo={props.event}
onClose={closeReplyForm}
onPost={closeReplyForm}
/>
</Show>
}
onShowProfile={() => {
showProfile(event().pubkey);
}}
onShowEvent={() => {
timelineContext?.setTimeline({ type: 'Replies', event: props.event });
}}
/>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import { Component } from 'solid-js';
import Bell from 'heroicons/24/outline/bell.svg';
import ChatBubbleLeftRight from 'heroicons/24/outline/chat-bubble-left-right.svg';
import GlobeAlt from 'heroicons/24/outline/globe-alt.svg';
import Heart from 'heroicons/24/outline/heart.svg';
import Home from 'heroicons/24/outline/home.svg';
@@ -103,6 +104,17 @@ const AddColumn: Component<AddColumnProps> = (props) => {
</span>
</button>
{/*
<button
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
onClick={() => window.alert()}
>
<span class="inline-block h-8 w-8">
<ChatBubbleLeftRight />
</span>
チャンネル
</button>
*/}
<button
class="flex basis-1/2 flex-col items-center gap-2 py-8 sm:basis-1/4"
onClick={() => addSearchColumn()}

View File

@@ -1,4 +1,4 @@
import { createSignal, Show, For, type JSX } from 'solid-js';
import { createSignal, Show, For, type JSX, batch } from 'solid-js';
import ArrowLeft from 'heroicons/24/outline/arrow-left.svg';
import EyeSlash from 'heroicons/24/outline/eye-slash.svg';
@@ -66,37 +66,81 @@ const RelayConfig = () => {
setRelayUrlInput('');
};
const importFromNIP07 = async () => {
if (window.nostr == null) return;
const importedRelays = Object.entries((await window.nostr?.getRelays?.()) ?? []);
const relayUrls = importedRelays.map(([relayUrl]) => relayUrl).join('\n');
if (importedRelays.length === 0) {
window.alert('リレーが設定されていません');
return;
}
if (!window.confirm(`これらのリレーをインポートしますか:\n${relayUrls}`)) {
return;
}
const lastCount = config().relayUrls.length;
batch(() => {
importedRelays.forEach(([relayUrl]) => {
addRelay(relayUrl);
});
});
const currentCount = config().relayUrls.length;
const importedCount = currentCount - lastCount;
window.alert(`${importedCount} 個のリレーをインポートしました`);
};
return (
<div class="py-2">
<h3 class="font-bold"></h3>
<ul>
<For each={config().relayUrls}>
{(relayUrl: string) => {
return (
<li class="flex items-center">
<div class="flex-1 truncate">{relayUrl}</div>
<button class="h-3 w-3 shrink-0" onClick={() => removeRelay(relayUrl)}>
<XMark />
</button>
</li>
);
<>
<div class="py-2">
<h3 class="font-bold"></h3>
<p class="py-1">{config().relayUrls.length} </p>
<ul>
<For each={config().relayUrls}>
{(relayUrl: string) => {
return (
<li class="flex items-center">
<div class="flex-1 truncate">{relayUrl}</div>
<button class="h-3 w-3 shrink-0" onClick={() => removeRelay(relayUrl)}>
<XMark />
</button>
</li>
);
}}
</For>
</ul>
<form class="flex gap-2" onSubmit={handleClickAddRelay}>
<input
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="relayUrl"
value={relayUrlInput()}
pattern={RelayUrlRegex}
onChange={(ev) => setRelayUrlInput(ev.currentTarget.value)}
/>
<button type="submit" class="rounded bg-rose-300 p-2 font-bold text-white">
</button>
</form>
</div>
<div class="py-2">
<h3 class="pb-1 font-bold"></h3>
<button
type="button"
class="rounded bg-rose-300 p-2 font-bold text-white"
onClick={() => {
importFromNIP07().catch((err) => {
console.error('failed to import relays', err);
window.alert('インポートに失敗しました');
});
}}
</For>
</ul>
<form class="flex gap-2" onSubmit={handleClickAddRelay}>
<input
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="relayUrl"
value={relayUrlInput()}
pattern={RelayUrlRegex}
onChange={(ev) => setRelayUrlInput(ev.currentTarget.value)}
/>
<button type="submit" class="rounded bg-rose-300 p-2 font-bold text-white">
>
</button>
</form>
</div>
</div>
</>
);
};

View File

@@ -9,7 +9,7 @@ import BasicModal from '@/components/modal/BasicModal';
import useConfig from '@/core/useConfig';
import { Profile } from '@/nostr/event/Profile';
import useCommands from '@/nostr/useCommands';
import { useProfile } from '@/nostr/useProfile';
import useProfile from '@/nostr/useProfile';
import usePubkey from '@/nostr/usePubkey';
import ensureNonNull from '@/utils/ensureNonNull';
import timeout from '@/utils/timeout';

View File

@@ -77,6 +77,12 @@ export type ReactionsColumnType = BaseColumn & {
pubkey: string;
};
/** A column which shows reactions published by the specific user */
export type ChannelColumnType = BaseColumn & {
columnType: 'Channel';
rootEventId: string;
};
/** A column which shows text notes and reposts posted to the specific relays */
export type RelaysColumnType = BaseColumn & {
columnType: 'Relays';
@@ -98,9 +104,10 @@ export type CustomFilterColumnType = BaseColumn & {
export type ColumnType =
| FollowingColumnType
| NotificationColumnType
| RelaysColumnType
| PostsColumnType
| ReactionsColumnType
| ChannelColumnType
| RelaysColumnType
| SearchColumnType
| CustomFilterColumnType;
@@ -159,6 +166,14 @@ export const createReactionsColumn = (
...params,
});
export const createChannelColumn = (
params: CreateParams<ChannelColumnType>,
): ChannelColumnType => ({
...createBaseColumn(),
columnType: 'Channel',
...params,
});
export const createSearchColumn = (params: CreateParams<SearchColumnType>): SearchColumnType => ({
...createBaseColumn(),
columnType: 'Search',

View File

@@ -0,0 +1,20 @@
import { createSignal, onMount } from 'solid-js';
const useDetectOverflow = () => {
let elementRef: HTMLElement | undefined;
const [overflow, setOverflow] = createSignal(false);
const setElementRef = (el: HTMLElement) => {
elementRef = el;
};
onMount(() => {
if (elementRef != null) {
setOverflow(elementRef.scrollHeight > elementRef.clientHeight);
}
});
return { overflow, elementRef: setElementRef };
};
export default useDetectOverflow;

View File

View File

@@ -74,7 +74,7 @@ const getProfile = ({
return timeout(15000, `useProfile: ${pubkey}`)(promise);
};
export const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
const useProfile = (propsProvider: () => UseProfileProps | null): UseProfile => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const genQueryKey = createMemo((): UseProfileQueryKey => ['useProfile', props()] as const);

View File

@@ -17,7 +17,7 @@ export type UseReposts = {
query: CreateQueryResult<NostrEvent[]>;
};
export const useReposts = (propsProvider: () => UseRepostsProps): UseReposts => {
const useReposts = (propsProvider: () => UseRepostsProps): UseReposts => {
const queryClient = useQueryClient();
const props = createMemo(propsProvider);
const genQueryKey = createMemo(() => ['useReposts', props()] as const);

View File

@@ -23,10 +23,14 @@ const Home: Component = () => {
createEffect(() => {
config().relayUrls.map(async (relayUrl) => {
const relay = await pool().ensureRelay(relayUrl);
relay.on('notice', (msg: string) => {
console.error(`NOTICE: ${relayUrl}: ${msg}`);
});
try {
const relay = await pool().ensureRelay(relayUrl);
relay.on('notice', (msg: string) => {
console.error(`NOTICE: ${relayUrl}: ${msg}`);
});
} catch (err) {
console.error('ensureRelay failed', err);
}
});
});