mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
update
This commit is contained in:
13
.eslintrc.js
13
.eslintrc.js
@@ -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: [
|
||||
|
||||
11
src/components/EventLink.tsx
Normal file
11
src/components/EventLink.tsx
Normal 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;
|
||||
@@ -44,7 +44,7 @@ const SideBar: Component = () => {
|
||||
content,
|
||||
})
|
||||
.then(() => {
|
||||
console.log('ok');
|
||||
console.log('succeeded to post');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('error', err);
|
||||
|
||||
0
src/components/textNote/Bech32EntityDisplay.tsx
Normal file
0
src/components/textNote/Bech32EntityDisplay.tsx
Normal file
41
src/components/textNote/ContentWarningDisplay.tsx
Normal file
41
src/components/textNote/ContentWarningDisplay.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>[]>([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
2
src/types/nostr.d.ts
vendored
2
src/types/nostr.d.ts
vendored
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user