This commit is contained in:
Shusui MOYATANI
2023-03-15 08:32:49 +09:00
parent 19b2a8c367
commit 158d0e3a20
16 changed files with 192 additions and 38 deletions

View File

@@ -63,7 +63,18 @@ module.exports = {
}, },
], ],
tailwindcss: { tailwindcss: {
whitelist: ['form-input'], whitelist: [
'form-input',
// rabbit parts
'nostr-textnote',
'author',
'author-icon',
'author-name',
'author-username',
'created-at',
'actions',
'content',
],
}, },
}, },
overrides: [ overrides: [

View File

@@ -0,0 +1,11 @@
import { Component } from 'solid-js';
type EventLinkProps = {
eventId: string;
};
const EventLink: Component<EventLinkProps> = (props) => (
<span class="text-blue-500 underline">{props.eventId}</span>
);
export default EventLink;

View File

@@ -44,7 +44,7 @@ const SideBar: Component = () => {
content, content,
}) })
.then(() => { .then(() => {
console.log('ok'); console.log('succeeded to post');
}) })
.catch((err) => { .catch((err) => {
console.error('error', err); console.error('error', err);

View File

@@ -0,0 +1,41 @@
import { createSignal, type Component, type JSX, Show } from 'solid-js';
import { ContentWarning } from '@/core/event';
export type ContentWarningDisplayProps = {
contentWarning: ContentWarning;
children: JSX.Element;
};
const ContentWarningDisplay: Component<ContentWarningDisplayProps> = (props) => {
const [showContentWarning, setShowContentWarning] = createSignal(false);
return (
<Show
when={!props.contentWarning.contentWarning || showContentWarning()}
fallback={
<button
class="mt-2 w-full rounded border p-2 text-center text-xs text-stone-600 shadow-sm hover:shadow"
onClick={() => setShowContentWarning(true)}
>
<Show when={props.contentWarning.reason != null}>
<br />
<span>: {props.contentWarning.reason}</span>
</Show>
</button>
}
>
<div>{props.children}</div>
<Show when={props.contentWarning.contentWarning}>
<button
class="text-xs text-stone-600 hover:text-stone-800"
onClick={() => setShowContentWarning(false)}
>
</button>
</Show>
</Show>
);
};
export default ContentWarningDisplay;

View File

@@ -1,7 +1,9 @@
import { Component } from 'solid-js'; import { Component, createSignal, Show } from 'solid-js';
import { ContentWarning } from '@/core/event';
type ImageDisplayProps = { type ImageDisplayProps = {
url: string; url: string;
contentWarning: ContentWarning;
}; };
const fixUrl = (url: URL): string => { const fixUrl = (url: URL): string => {
@@ -20,16 +22,29 @@ const fixUrl = (url: URL): string => {
}; };
const ImageDisplay: Component<ImageDisplayProps> = (props) => { const ImageDisplay: Component<ImageDisplayProps> = (props) => {
const [hidden, setHidden] = createSignal(props.contentWarning.contentWarning);
const url = () => new URL(props.url); const url = () => new URL(props.url);
return ( return (
<a class="my-2 block" href={props.url} target="_blank" rel="noopener noreferrer"> <Show
<img when={!hidden()}
class="inline-block max-h-64 max-w-full rounded object-contain shadow hover:shadow-md" fallback={
src={fixUrl(url())} <button
alt={props.url} class="rounded bg-stone-300 p-3 text-xs text-stone-600 hover:shadow"
/> onClick={() => setHidden(false)}
</a> >
</button>
}
>
<a class="my-2 block" href={props.url} target="_blank" rel="noopener noreferrer">
<img
class="inline-block max-h-64 max-w-full rounded object-contain shadow hover:shadow-md"
src={fixUrl(url())}
alt={props.url}
/>
</a>
</Show>
); );
}; };

View File

@@ -1,6 +1,7 @@
import { Show } from 'solid-js'; import { Show } from 'solid-js';
import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById'; import TextNoteDisplayById from '@/components/textNote/TextNoteDisplayById';
import { type MentionedEvent } from '@/core/parseTextNote'; import { type MentionedEvent } from '@/core/parseTextNote';
import EventLink from '../EventLink';
export type MentionedEventDisplayProps = { export type MentionedEventDisplayProps = {
mentionedEvent: MentionedEvent; mentionedEvent: MentionedEvent;
@@ -9,8 +10,15 @@ export type MentionedEventDisplayProps = {
const MentionedEventDisplay = (props: MentionedEventDisplayProps) => { const MentionedEventDisplay = (props: MentionedEventDisplayProps) => {
return ( return (
<Show <Show
when={props.mentionedEvent.marker == null || props.mentionedEvent.marker === 'mention'} when={
fallback={<span class="text-blue-500 underline">{props.mentionedEvent.eventId}</span>} props.mentionedEvent.marker == null ||
props.mentionedEvent.marker.length === 0 ||
props.mentionedEvent.marker === 'mention'
}
fallback={() => {
console.log(props.mentionedEvent);
return <EventLink eventId={props.mentionedEvent.eventId} />;
}}
> >
<div class="my-1 rounded border p-1"> <div class="my-1 rounded border p-1">
<TextNoteDisplayById <TextNoteDisplayById

View File

@@ -5,6 +5,9 @@ import PlainTextDisplay from '@/components/textNote/PlainTextDisplay';
import MentionedUserDisplay from '@/components/textNote/MentionedUserDisplay'; import MentionedUserDisplay from '@/components/textNote/MentionedUserDisplay';
import MentionedEventDisplay from '@/components/textNote/MentionedEventDisplay'; import MentionedEventDisplay from '@/components/textNote/MentionedEventDisplay';
import ImageDisplay from '@/components/textNote/ImageDisplay'; import ImageDisplay from '@/components/textNote/ImageDisplay';
import eventWrapper from '@/core/event';
import EventLink from '../EventLink';
import TextNoteDisplayById from './TextNoteDisplayById';
export type TextNoteContentDisplayProps = { export type TextNoteContentDisplayProps = {
event: NostrEvent; event: NostrEvent;
@@ -12,6 +15,7 @@ export type TextNoteContentDisplayProps = {
}; };
const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => { const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
const event = () => eventWrapper(props.event);
return ( return (
<For each={parseTextNote(props.event)}> <For each={parseTextNote(props.event)}>
{(item: ParsedTextNoteNode) => { {(item: ParsedTextNoteNode) => {
@@ -25,17 +29,24 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
if (props.embedding) { if (props.embedding) {
return <MentionedEventDisplay mentionedEvent={item} />; return <MentionedEventDisplay mentionedEvent={item} />;
} }
return <div>mention</div>; return <EventLink eventId={item.eventId} />;
}
if (item.type === 'Bech32Entity') {
if (item.data.type === 'note' && props.embedding) {
return (
<div class="my-1 rounded border p-1">
<TextNoteDisplayById eventId={item.data.data} actions={false} />
</div>
);
}
return <span class="text-blue-500 underline">{item.content}</span>;
} }
if (item.type === 'HashTag') { if (item.type === 'HashTag') {
return <span class="text-blue-500 underline">{item.content}</span>; return <span class="text-blue-500 underline">{item.content}</span>;
} }
if (item.type === 'URL') { if (item.type === 'URL') {
if (item.content.match(/\.(jpeg|jpg|png|gif|webp)$/i)) { if (item.content.match(/\.(jpeg|jpg|png|gif|webp)$/i) && props.embedding) {
if (props.embedding) { return <ImageDisplay url={item.content} contentWarning={event().contentWarning()} />;
return <ImageDisplay url={item.content} />;
}
return <div>image</div>;
} }
return ( return (
<a <a

View File

@@ -24,6 +24,7 @@ import useDeprecatedReposts from '@/nostr/useDeprecatedReposts';
import useFormatDate from '@/hooks/useFormatDate'; import useFormatDate from '@/hooks/useFormatDate';
import ensureNonNull from '@/utils/ensureNonNull'; import ensureNonNull from '@/utils/ensureNonNull';
import ContentWarningDisplay from './ContentWarningDisplay';
export type TextNoteDisplayProps = { export type TextNoteDisplayProps = {
event: NostrEvent; event: NostrEvent;
@@ -31,12 +32,15 @@ export type TextNoteDisplayProps = {
actions?: boolean; actions?: boolean;
}; };
const ContentWarning = (props) => {};
const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => { const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const { config } = useConfig(); const { config } = useConfig();
const formatDate = useFormatDate(); const formatDate = useFormatDate();
const commands = useCommands(); const commands = useCommands();
const pubkey = usePubkey(); const pubkey = usePubkey();
const [showReplyForm, setShowReplyForm] = createSignal(false); const [showReplyForm, setShowReplyForm] = createSignal(false);
const [showContentWarning, setShowContentWarning] = createSignal(false);
const event = createMemo(() => eventWrapper(props.event)); const event = createMemo(() => eventWrapper(props.event));
@@ -93,7 +97,8 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
eventId: eventIdNonNull, eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey, notifyPubkey: props.event.pubkey,
}) })
.then(() => invalidateDeprecatedReposts()); .then(() => invalidateDeprecatedReposts())
.catch((err) => console.error('failed to repost: ', err));
}); });
}; };
@@ -113,12 +118,13 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
eventId: eventIdNonNull, eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey, notifyPubkey: props.event.pubkey,
}) })
.then(() => invalidateReactions()); .then(() => invalidateReactions())
.catch((err) => console.error('failed to publish reaction: ', err));
}); });
}; };
return ( return (
<div class="flex flex-col"> <div class="nostr-textnote flex flex-col">
<div class="flex w-full gap-1"> <div class="flex w-full gap-1">
<div class="author-icon h-10 w-10 shrink-0"> <div class="author-icon h-10 w-10 shrink-0">
<Show when={author()?.picture}> <Show when={author()?.picture}>
@@ -158,11 +164,13 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
</For> </For>
</div> </div>
</Show> </Show>
<div class="content whitespace-pre-wrap break-all"> <ContentWarningDisplay contentWarning={event().contentWarning()}>
<TextNoteContentDisplay event={props.event} embedding={embedding()} /> <div class="content whitespace-pre-wrap break-all">
</div> <TextNoteContentDisplay event={props.event} embedding={embedding()} />
</div>
</ContentWarningDisplay>
<Show when={actions()}> <Show when={actions()}>
<div class="flex w-48 items-center justify-between gap-8 pt-1"> <div class="actions flex w-48 items-center justify-between gap-8 pt-1">
<button <button
class="h-4 w-4 text-zinc-400" class="h-4 w-4 text-zinc-400"
onClick={() => setShowReplyForm((current) => !current)} onClick={() => setShowReplyForm((current) => !current)}

View File

@@ -2,12 +2,18 @@ import type { Event as NostrEvent } from 'nostr-tools';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
export type EventMarker = 'reply' | 'root' | 'mention'; export type EventMarker = 'reply' | 'root' | 'mention';
export type TaggedEvent = { export type TaggedEvent = {
id: string; id: string;
relayUrl?: string; relayUrl?: string;
marker: EventMarker; marker: EventMarker;
}; };
export type ContentWarning = {
contentWarning: boolean;
reason?: string;
};
const eventWrapper = (event: NostrEvent) => { const eventWrapper = (event: NostrEvent) => {
return { return {
get rawEvent(): NostrEvent { get rawEvent(): NostrEvent {
@@ -58,7 +64,7 @@ const eventWrapper = (event: NostrEvent) => {
return events.map(([, eventId, relayUrl, marker], index) => ({ return events.map(([, eventId, relayUrl, marker], index) => ({
id: eventId, id: eventId,
relayUrl, relayUrl,
marker: (marker as EventMarker) ?? positionToMarker(index), marker: (marker as EventMarker | undefined) ?? positionToMarker(index),
})); }));
}, },
replyingToEvent(): TaggedEvent | undefined { replyingToEvent(): TaggedEvent | undefined {
@@ -73,6 +79,13 @@ const eventWrapper = (event: NostrEvent) => {
mentionedPubkeys(): string[] { mentionedPubkeys(): string[] {
return uniq(event.tags.filter(([tagName]) => tagName === 'p').map((e) => e[1])); return uniq(event.tags.filter(([tagName]) => tagName === 'p').map((e) => e[1]));
}, },
contentWarning(): ContentWarning {
const tag = event.tags.find(([tagName]) => tagName === 'content-warning');
if (tag == null) return { contentWarning: false };
const reason = (tag[1]?.length ?? 0) > 0 ? tag[1] : undefined;
return { contentWarning: true, reason };
},
}; };
}; };

View File

@@ -1,4 +1,5 @@
import type { Event as NostrEvent } from 'nostr-tools'; import type { Event as NostrEvent } from 'nostr-tools';
import { decode, type ProfilePointer, type EventPointer } from 'nostr-tools/nip19';
export type PlainText = { export type PlainText = {
type: 'PlainText'; type: 'PlainText';
@@ -7,19 +8,29 @@ export type PlainText = {
export type MentionedEvent = { export type MentionedEvent = {
type: 'MentionedEvent'; type: 'MentionedEvent';
tagIndex: number;
content: string; content: string;
tagIndex: number;
eventId: string; eventId: string;
marker: string | null; // TODO 'reply' | 'root' | 'mention' | null; marker: string | null; // TODO 'reply' | 'root' | 'mention' | null;
}; };
export type MentionedUser = { export type MentionedUser = {
type: 'MentionedUser'; type: 'MentionedUser';
tagIndex: number;
content: string; content: string;
tagIndex: number;
pubkey: string; pubkey: string;
}; };
export type Bech32Entity = {
type: 'Bech32Entity';
content: string;
data:
| { type: 'npub' | 'note'; data: string }
| { type: 'nprofile'; data: ProfilePointer }
| { type: 'nevent'; data: EventPointer }
| { type: 'naddr'; data: AddressPointer };
};
export type HashTag = { export type HashTag = {
type: 'HashTag'; type: 'HashTag';
content: string; content: string;
@@ -31,16 +42,25 @@ export type UrlText = {
content: string; content: string;
}; };
export type ParsedTextNoteNode = PlainText | MentionedEvent | MentionedUser | HashTag | UrlText; export type ParsedTextNoteNode =
| PlainText
| MentionedEvent
| MentionedUser
| Bech32Entity
| HashTag
| UrlText;
export type ParsedTextNote = ParsedTextNoteNode[]; export type ParsedTextNote = ParsedTextNoteNode[];
export const parseTextNote = (event: NostrEvent): ParsedTextNote => { export const parseTextNote = (event: NostrEvent): ParsedTextNote => {
const matches = [ const matches = [
...event.content.matchAll(/(?:#\[(?<idx>\d+)\])/g), ...event.content.matchAll(/(?:#\[(?<idx>\d+)\])/g),
...event.content.matchAll(/#(?<hashtag>[^[\]()\d\s][^[\]()\s]+)/g), ...event.content.matchAll(/#(?<hashtag>[^[-`:-@!-/{-~\d\s][^[-`:-@!-/{-~\s]+)/g),
...event.content.matchAll( ...event.content.matchAll(
/(?<url>https?:\/\/[-a-zA-Z0-9.]+(?:\/[-\w.%:]+|\/)*(?:\?[-\w=.%:&]*)?(?:#[-\w=.%:&]*)?)/g, /(?<nip19>(npub|note|nprofile|nevent|nrelay|naddr)1[ac-hj-np-z02-9]+)/gi,
),
...event.content.matchAll(
/(?<url>(https?|wss?):\/\/[-a-zA-Z0-9.]+(?:\/[-\w.@%:]+|\/)*(?:\?[-\w=.@%:&]*)?(?:#[-\w=.%:&]*)?)/g,
), ),
].sort((a, b) => a?.index - b?.index); ].sort((a, b) => a?.index - b?.index);
let pos = 0; let pos = 0;
@@ -76,15 +96,28 @@ export const parseTextNote = (event: NostrEvent): ParsedTextNote => {
}; };
result.push(mentionedUser); result.push(mentionedUser);
} else if (tagName === 'e') { } else if (tagName === 'e') {
const marker = tag[2] != null && tag[2].length > 0 ? tag[2] : null;
const mentionedEvent: MentionedEvent = { const mentionedEvent: MentionedEvent = {
type: 'MentionedEvent', type: 'MentionedEvent',
tagIndex, tagIndex,
content: match[0], content: match[0],
eventId: tag[1], eventId: tag[1],
marker: tag[2], marker,
}; };
result.push(mentionedEvent); result.push(mentionedEvent);
} }
} else if (match.groups?.nip19 && match.index >= pos) {
try {
const decoded = decode(match[0]);
const bech32Entity: Bech32Entity = {
type: 'Bech32Entity',
content: match[0],
data: decoded as Bech32Entity['data'],
};
result.push(bech32Entity);
} catch (e) {
console.error(`failed to parse Bech32 entity (NIP-19) but ignore this: ${match[0]}`);
}
} else if (match.groups?.hashtag && match.index >= pos) { } else if (match.groups?.hashtag && match.index >= pos) {
pushPlainText(match.index); pushPlainText(match.index);
const tagName = match.groups?.hashtag; const tagName = match.groups?.hashtag;

View File

@@ -55,6 +55,7 @@ const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcu
onMount(() => { onMount(() => {
const handleKeydown = throttle((ev: KeyboardEvent) => { const handleKeydown = throttle((ev: KeyboardEvent) => {
if (ev.type !== 'keydown') return; if (ev.type !== 'keydown') return;
if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
if (ev.target instanceof HTMLTextAreaElement || ev.target instanceof HTMLInputElement) return; if (ev.target instanceof HTMLTextAreaElement || ev.target instanceof HTMLInputElement) return;
const shortcut = shortcutsMap.get(ev.key); const shortcut = shortcutsMap.get(ev.key);

View File

@@ -40,7 +40,7 @@ const useBatch = <TaskArgs, TaskResult>(
) => { ) => {
const props = createMemo(propsProvider); const props = createMemo(propsProvider);
const batchSize = createMemo(() => props().batchSize ?? 100); const batchSize = createMemo(() => props().batchSize ?? 100);
const interval = createMemo(() => props().interval ?? 1000); const interval = createMemo(() => props().interval ?? 2000);
const [seqId, setSeqId] = createSignal<number>(0); const [seqId, setSeqId] = createSignal<number>(0);
const [taskQueue, setTaskQueue] = createSignal<Task<TaskArgs, TaskResult>[]>([]); const [taskQueue, setTaskQueue] = createSignal<Task<TaskArgs, TaskResult>[]>([]);

View File

@@ -44,6 +44,7 @@ const useCommands = () => {
relayUrls, relayUrls,
pubkey, pubkey,
content, content,
tags,
notifyPubkeys, notifyPubkeys,
rootEventId, rootEventId,
mentionEventIds, mentionEventIds,
@@ -52,6 +53,7 @@ const useCommands = () => {
relayUrls: string[]; relayUrls: string[];
pubkey: string; pubkey: string;
content: string; content: string;
tags?: string[][];
notifyPubkeys?: string[]; notifyPubkeys?: string[];
rootEventId?: string; rootEventId?: string;
mentionEventIds?: string[]; mentionEventIds?: string[];
@@ -65,13 +67,13 @@ const useCommands = () => {
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention'])); mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
if (replyEventId != null) eTags.push(['e', replyEventId, '', 'reply']); if (replyEventId != null) eTags.push(['e', replyEventId, '', 'reply']);
const tags = [...pTags, ...eTags]; const mergedTags = [...eTags, ...pTags, ...(tags ?? [])];
const preSignedEvent: NostrEvent = { const preSignedEvent: NostrEvent = {
kind: 1, kind: 1,
pubkey, pubkey,
created_at: currentDate(), created_at: currentDate(),
tags, tags: mergedTags,
content, content,
}; };
return publishEvent(relayUrls, preSignedEvent); return publishEvent(relayUrls, preSignedEvent);

View File

@@ -146,10 +146,10 @@ const Home: Component = () => {
<Column name="日本リレー" columnIndex={3} width="medium"> <Column name="日本リレー" columnIndex={3} width="medium">
<Timeline events={localTimeline()} /> <Timeline events={localTimeline()} />
</Column> </Column>
<Column name="自分の投稿" columnIndex={4} lastColumn width="medium"> <Column name="自分の投稿" columnIndex={4} width="medium">
<Timeline events={myPosts()} /> <Timeline events={myPosts()} />
</Column> </Column>
<Column name="自分のいいね" columnIndex={4} lastColumn width="medium"> <Column name="自分のいいね" columnIndex={5} lastColumn width="medium">
<Notification events={myReactions()} /> <Notification events={myReactions()} />
</Column> </Column>
</div> </div>

View File

@@ -14,7 +14,7 @@ type NostrAPI = {
getRelays?(): Promise<{ [url: string]: { read: boolean; write: boolean } }>; getRelays?(): Promise<{ [url: string]: { read: boolean; write: boolean } }>;
/** NIP-04: Encrypted Direct Messages */ /** NIP-04: Encrypted Direct Messages */
nip04: { nip04?: {
/** returns ciphertext and iv as specified in nip-04 */ /** returns ciphertext and iv as specified in nip-04 */
encrypt(pubkey: string, plaintext: string): Promise<string>; encrypt(pubkey: string, plaintext: string): Promise<string>;
/** takes ciphertext and iv as specified in nip-04 */ /** takes ciphertext and iv as specified in nip-04 */