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: {
whitelist: ['form-input'],
whitelist: [
'form-input',
// rabbit parts
'nostr-textnote',
'author',
'author-icon',
'author-name',
'author-username',
'created-at',
'actions',
'content',
],
},
},
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,
})
.then(() => {
console.log('ok');
console.log('succeeded to post');
})
.catch((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 = {
url: string;
contentWarning: ContentWarning;
};
const fixUrl = (url: URL): string => {
@@ -20,9 +22,21 @@ const fixUrl = (url: URL): string => {
};
const ImageDisplay: Component<ImageDisplayProps> = (props) => {
const [hidden, setHidden] = createSignal(props.contentWarning.contentWarning);
const url = () => new URL(props.url);
return (
<Show
when={!hidden()}
fallback={
<button
class="rounded bg-stone-300 p-3 text-xs text-stone-600 hover:shadow"
onClick={() => setHidden(false)}
>
</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"
@@ -30,6 +44,7 @@ const ImageDisplay: Component<ImageDisplayProps> = (props) => {
alt={props.url}
/>
</a>
</Show>
);
};

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,18 @@ import type { Event as NostrEvent } from 'nostr-tools';
import uniq from 'lodash/uniq';
export type EventMarker = 'reply' | 'root' | 'mention';
export type TaggedEvent = {
id: string;
relayUrl?: string;
marker: EventMarker;
};
export type ContentWarning = {
contentWarning: boolean;
reason?: string;
};
const eventWrapper = (event: NostrEvent) => {
return {
get rawEvent(): NostrEvent {
@@ -58,7 +64,7 @@ const eventWrapper = (event: NostrEvent) => {
return events.map(([, eventId, relayUrl, marker], index) => ({
id: eventId,
relayUrl,
marker: (marker as EventMarker) ?? positionToMarker(index),
marker: (marker as EventMarker | undefined) ?? positionToMarker(index),
}));
},
replyingToEvent(): TaggedEvent | undefined {
@@ -73,6 +79,13 @@ const eventWrapper = (event: NostrEvent) => {
mentionedPubkeys(): string[] {
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 { decode, type ProfilePointer, type EventPointer } from 'nostr-tools/nip19';
export type PlainText = {
type: 'PlainText';
@@ -7,19 +8,29 @@ export type PlainText = {
export type MentionedEvent = {
type: 'MentionedEvent';
tagIndex: number;
content: string;
tagIndex: number;
eventId: string;
marker: string | null; // TODO 'reply' | 'root' | 'mention' | null;
};
export type MentionedUser = {
type: 'MentionedUser';
tagIndex: number;
content: string;
tagIndex: number;
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 = {
type: 'HashTag';
content: string;
@@ -31,16 +42,25 @@ export type UrlText = {
content: string;
};
export type ParsedTextNoteNode = PlainText | MentionedEvent | MentionedUser | HashTag | UrlText;
export type ParsedTextNoteNode =
| PlainText
| MentionedEvent
| MentionedUser
| Bech32Entity
| HashTag
| UrlText;
export type ParsedTextNote = ParsedTextNoteNode[];
export const parseTextNote = (event: NostrEvent): ParsedTextNote => {
const matches = [
...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(
/(?<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);
let pos = 0;
@@ -76,15 +96,28 @@ export const parseTextNote = (event: NostrEvent): ParsedTextNote => {
};
result.push(mentionedUser);
} else if (tagName === 'e') {
const marker = tag[2] != null && tag[2].length > 0 ? tag[2] : null;
const mentionedEvent: MentionedEvent = {
type: 'MentionedEvent',
tagIndex,
content: match[0],
eventId: tag[1],
marker: tag[2],
marker,
};
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) {
pushPlainText(match.index);
const tagName = match.groups?.hashtag;

View File

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

View File

@@ -40,7 +40,7 @@ const useBatch = <TaskArgs, TaskResult>(
) => {
const props = createMemo(propsProvider);
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 [taskQueue, setTaskQueue] = createSignal<Task<TaskArgs, TaskResult>[]>([]);

View File

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

View File

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

View File

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