diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 4af65ec..302dd78 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,4 +1,6 @@ -import { createSignal, onCleanup, createEffect, For, type Component, type JSX } from 'solid-js'; +import { For, type Component, type JSX } from 'solid-js'; + +import Popup, { PopupRef } from '@/components/utils/Popup'; export type MenuItem = { content: () => JSX.Element; @@ -32,60 +34,24 @@ const MenuItemDisplay: Component = (props) => { }; const ContextMenu: Component = (props) => { - let menuRef: HTMLUListElement | undefined; + let popupRef: PopupRef | undefined; - const [isOpen, setIsOpen] = createSignal(false); - - const handleClickOutside = (ev: MouseEvent) => { - const target = ev.target as HTMLElement; - if (target != null && !menuRef?.contains(target)) { - setIsOpen(false); - } - }; - const addClickOutsideHandler = () => { - document.addEventListener('mousedown', handleClickOutside); - }; - const removeClickOutsideHandler = () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - - const open = () => setIsOpen(true); - const close = () => setIsOpen(false); - - const handleClick: JSX.EventHandler = (ev) => { - if (menuRef == null) return; - - const buttonRect = ev.currentTarget.getBoundingClientRect(); - // const menuRect = menuRef.getBoundingClientRect(); - menuRef.style.left = `${buttonRect.left - buttonRect.width}px`; - menuRef.style.top = `${buttonRect.top + buttonRect.height}px`; - - open(); - }; - - createEffect(() => { - if (isOpen()) { - addClickOutsideHandler(); - } else { - removeClickOutsideHandler(); - } - }); - - onCleanup(() => removeClickOutsideHandler()); + const close = () => popupRef?.close(); return ( -
- -
    + { + popupRef = e; + }} + button={props.children} + position="bottom" + > +
      e.when == null || e.when())}> {(item: MenuItem) => }
    -
+ ); }; diff --git a/src/components/modal/ProfileEdit.tsx b/src/components/modal/ProfileEdit.tsx index aafabc3..9c55ca2 100644 --- a/src/components/modal/ProfileEdit.tsx +++ b/src/components/modal/ProfileEdit.tsx @@ -18,14 +18,14 @@ export type ProfileEditProps = { }; const LNURLRegexString = '(LNURL1[AC-HJ-NP-Z02-9]+|lnurl1[ac-hj-np-z02-9]+)'; -const InternetIdentiferRegexString = '[-_a-zA-Z0-9.]+@[-a-zA-Z0-9.]+'; -const LUDAddressRegexString = `^(${LNURLRegexString}|${InternetIdentiferRegexString})$`; +const InternetIdentifierRegexString = '[-_a-zA-Z0-9.]+@[-a-zA-Z0-9.]+'; +const LUDAddressRegexString = `^(${LNURLRegexString}|${InternetIdentifierRegexString})$`; const LNURLRegex = new RegExp(`^${LNURLRegexString}$`); -const InternetIdentiferRegex = new RegExp(`^${InternetIdentiferRegexString}$`); +const InternetIdentifierRegex = new RegExp(`^${InternetIdentifierRegexString}$`); const isLNURL = (s: string) => LNURLRegex.test(s); -const isInternetIdentifier = (s: string) => InternetIdentiferRegex.test(s); +const isInternetIdentifier = (s: string) => InternetIdentifierRegex.test(s); const ProfileEdit: Component = (props) => { const pubkey = usePubkey(); @@ -260,7 +260,7 @@ const ProfileEdit: Component = (props) => { name="nip05" value={nip05()} placeholder="yourname@domain.example.com" - pattern={InternetIdentiferRegex.source} + pattern={InternetIdentifierRegex.source} disabled={disabled()} onChange={(ev) => setNIP05(ev.currentTarget.value)} onKeyDown={ignoreEnter} diff --git a/src/components/notification/Reaction.tsx b/src/components/notification/Reaction.tsx index 7aff5f0..d7a2685 100644 --- a/src/components/notification/Reaction.tsx +++ b/src/components/notification/Reaction.tsx @@ -10,6 +10,7 @@ import useModalState from '@/hooks/useModalState'; import eventWrapper from '@/nostr/event'; import useEvent from '@/nostr/useEvent'; import useProfile from '@/nostr/useProfile'; +import ensureNonNull from '@/utils/ensureNonNull'; type ReactionProps = { event: NostrEvent; @@ -18,14 +19,18 @@ type ReactionProps = { const Reaction: Component = (props) => { const { showProfile } = useModalState(); const event = () => eventWrapper(props.event); - const eventId = () => event().taggedEvents()[0].id; + const eventId = () => event().lastTaggedEventId(); const { profile } = useProfile(() => ({ pubkey: props.event.pubkey, })); - const { event: reactedEvent, query: reactedEventQuery } = useEvent(() => ({ - eventId: eventId(), - })); + + const { event: reactedEvent, query: reactedEventQuery } = useEvent(() => + ensureNonNull([eventId()] as const)(([eventIdNonNull]) => ({ + eventId: eventIdNonNull, + })), + ); + const isRemoved = () => reactedEventQuery.isSuccess && reactedEvent() == null; return ( @@ -67,7 +72,7 @@ const Reaction: Component = (props) => {
loading {eventId()}
} + fallback={
読み込み中 {eventId()}
} keyed > {(ev) => } diff --git a/src/components/textNote/TextNoteDisplay.tsx b/src/components/textNote/TextNoteDisplay.tsx index 086912c..264111b 100644 --- a/src/components/textNote/TextNoteDisplay.tsx +++ b/src/components/textNote/TextNoteDisplay.tsx @@ -37,8 +37,52 @@ export type TextNoteDisplayProps = { actions?: boolean; }; +type EmojiReactionsProps = { + reactionsGroupedByContent: Map; + onReaction: (emoji: string) => void; +}; + const { noteEncode } = nip19; +const EmojiReactions: Component = (props) => { + const { config } = useConfig(); + const pubkey = usePubkey(); + + return ( +
+ + {([content, events]) => { + const isReactedByMeWithThisContent = + events.findIndex((ev) => ev.pubkey === pubkey()) >= 0; + + return ( + + ); + }} + +
+ ); +}; + const TextNoteDisplay: Component = (props) => { let contentRef: HTMLDivElement | undefined; @@ -349,40 +393,10 @@ const TextNoteDisplay: Component = (props) => { 0}> -
- - {([content, events]) => { - const isReactedByMeWithThisContent = - events.findIndex((ev) => ev.pubkey === pubkey()) >= 0; - - return ( - - ); - }} - -
+
- + }> + + + 0 @@ -447,6 +450,15 @@ const TextNoteDisplay: Component = (props) => {
{reactions().length}
+ +
+ doReaction(emoji)}> + + + + +
+
diff --git a/src/components/utils/Popup.tsx b/src/components/utils/Popup.tsx index fb50a3e..9f93514 100644 --- a/src/components/utils/Popup.tsx +++ b/src/components/utils/Popup.tsx @@ -27,12 +27,16 @@ const Popup: Component = (props) => { let popupRef: HTMLDivElement | undefined; const [isOpen, setIsOpen] = createSignal(false); + const [style, setStyle] = createSignal({}); const resolvedChildren = children(() => props.children); + const close = () => setIsOpen(false); + const toggleOpened = () => setIsOpen((current) => !current); + const handleClickOutside = (ev: MouseEvent) => { const target = ev.target as HTMLElement; if (target != null && !popupRef?.contains(target)) { - setIsOpen(false); + close(); } }; @@ -43,10 +47,31 @@ const Popup: Component = (props) => { document.removeEventListener('mousedown', handleClickOutside); }; - const close = () => setIsOpen(false); - const toggle = () => setIsOpen((current) => !current); + const adjustPosition = () => { + if (buttonRef == null || popupRef == null) return; - const handleClick: JSX.EventHandler = () => toggle(); + const buttonRect = buttonRef?.getBoundingClientRect(); + const popupRect = popupRef?.getBoundingClientRect(); + + let { top, left } = buttonRect; + + if (props.position === 'left') { + left -= buttonRect.width; + } else if (props.position === 'right') { + left += buttonRect.width; + } else if (props.position === 'top') { + top -= buttonRect.height; + left -= buttonRect.left + buttonRect.width / 2; + } else { + top += buttonRect.height; + left += buttonRect.width / 2; + } + + top = Math.min(top, window.innerHeight - popupRect.height); + left = Math.min(left, window.innerWidth - popupRect.width); + + setStyle({ left: `${left}px`, top: `${top}px` }); + }; createEffect(() => { if (isOpen()) { @@ -60,34 +85,16 @@ const Popup: Component = (props) => { createEffect( on(resolvedChildren, () => { - if (buttonRef == null || popupRef == null) return; - - const buttonRect = buttonRef?.getBoundingClientRect(); - const popupRect = popupRef?.getBoundingClientRect(); - - let { top, left } = buttonRect; - - if (props.position === 'left') { - left -= buttonRect.width; - } else if (props.position === 'right') { - left += buttonRect.width; - } else if (props.position === 'top') { - top -= buttonRect.height; - left -= buttonRect.left + buttonRect.width / 2; - } else { - top += buttonRect.height; - left += buttonRect.width / 2; - } - - top = Math.min(top, window.innerHeight - popupRect.height); - left = Math.min(left, window.innerWidth - popupRect.width); - console.log(popupRect); - - popupRef.style.left = `${left}px`; - popupRef.style.top = `${top}px`; + adjustPosition(); }), ); + createEffect(() => { + if (isOpen()) { + adjustPosition(); + } + }); + onMount(() => { props.ref?.({ close }); }); @@ -96,10 +103,22 @@ const Popup: Component = (props) => { return (
- -
+
{resolvedChildren()}
diff --git a/src/nostr/event.test.ts b/src/nostr/event.test.ts new file mode 100644 index 0000000..10cb4ba --- /dev/null +++ b/src/nostr/event.test.ts @@ -0,0 +1,23 @@ +import assert from 'assert'; + +import { describe, it } from 'vitest'; + +import { isValidId } from '@/nostr/event'; + +describe('isValidId', () => { + it.each([ + '59cd96d24acd0679080679330a06ae9dcb5026ba080528fb93762c49050be8c9', + '005c079e4c7c103168e0cb359270ac96a6a46e5ff4ce8f4643e0831f6d1c2450', + ])('should return true if valid id is given (%s)', (id) => { + assert.deepStrictEqual(true, isValidId(id)); + }); + + it.each([ + 'd9553d75bb38da004d380168221fa8cb7c5c55f5242397c1459c7013a2a1264z', + 'a8e947afb422bbc44577a107147f684ab5c97538b505d2699181e8815160950z', + 'a8e947a', + '', + ])('should return false if invalid id is given (%s)', (id) => { + assert.deepStrictEqual(false, isValidId(id)); + }); +}); diff --git a/src/nostr/event.ts b/src/nostr/event.ts index 70d82f9..9d0f4d4 100644 --- a/src/nostr/event.ts +++ b/src/nostr/event.ts @@ -1,22 +1,37 @@ import uniq from 'lodash/uniq'; - -import type { Event as NostrEvent } from 'nostr-tools'; +import { Kind, Event as NostrEvent } from 'nostr-tools'; export type EventMarker = 'reply' | 'root' | 'mention'; -export type TaggedEvent = { +// NIP-10 +export type MarkedEventTag = { id: string; relayUrl?: string; index: number; marker?: EventMarker; }; +export type ContactPubkeyTag = { + pubkey: string; + relayUrl?: string; + petname?: string; +}; + export type ContentWarning = { contentWarning: boolean; reason?: string; }; +const IdRegex = /^[0-9a-fA-f]{64}$/; +export const isValidId = (s: string): boolean => { + const result = typeof s === 'string' && s.length === 64 && IdRegex.test(s); + if (!result) console.warn('invalid id is ignored: ', s); + return result; +}; + const eventWrapper = (event: NostrEvent) => { + let memoizedMarkedEventTags: MarkedEventTag[] | undefined; + return { get rawEvent(): NostrEvent { return event; @@ -33,22 +48,36 @@ const eventWrapper = (event: NostrEvent) => { get content(): string { return event.content; }, + get tags(): string[][] { + return event.tags; + }, createdAtAsDate(): Date { return new Date(event.created_at * 1000); }, - taggedUsers(): string[] { - const pubkeys = new Set(); - event.tags.forEach(([tagName, pubkey]) => { - if (tagName === 'p') { - pubkeys.add(pubkey); - } - }); - return Array.from(pubkeys); + pTags(): string[][] { + return event.tags.filter(([tagName, pubkey]) => tagName === 'p' && isValidId(pubkey)); }, - taggedEvents(): TaggedEvent[] { + eTags(): string[][] { + return event.tags.filter(([tagName, eventId]) => tagName === 'e' && isValidId(eventId)); + }, + taggedEventIds(): string[] { + return this.eTags().map(([, eventId]) => eventId); + }, + lastTaggedEventId(): string | undefined { + // for compatibility. some clients include additional event ids for reaction (kind:7). + const ids = this.taggedEventIds(); + if (ids.length === 0) return undefined; + return ids[ids.length - 1]; + }, + markedEventTags(): MarkedEventTag[] { + if (event.kind !== Kind.Text) throw new Error('kind should be 1'); + + if (memoizedMarkedEventTags != null) return memoizedMarkedEventTags; + + // 'eTags' cannot be used here because it does not preserve originalIndex. const events = event.tags .map((tag, originalIndex) => [tag, originalIndex] as const) - .filter(([[tagName]]) => tagName === 'e'); + .filter(([[tagName, eventId]]) => tagName === 'e' && isValidId(eventId)); // NIP-10: Positional "e" tags (DEPRECATED) const positionToMarker = (marker: string, index: number): EventMarker | undefined => { @@ -68,24 +97,28 @@ const eventWrapper = (event: NostrEvent) => { return 'mention'; }; - return events.map(([[, eventId, relayUrl, marker], originalIndex], eTagIndex) => ({ - id: eventId, - relayUrl, - marker: positionToMarker(marker, eTagIndex), - index: originalIndex, - })); + memoizedMarkedEventTags = events.map( + ([[, eventId, relayUrl, marker], originalIndex], eTagIndex) => ({ + id: eventId, + relayUrl, + marker: positionToMarker(marker, eTagIndex), + index: originalIndex, + }), + ); + + return memoizedMarkedEventTags; }, - replyingToEvent(): TaggedEvent | undefined { - return this.taggedEvents().find(({ marker }) => marker === 'reply'); + replyingToEvent(): MarkedEventTag | undefined { + return this.markedEventTags().find(({ marker }) => marker === 'reply'); }, - rootEvent(): TaggedEvent | undefined { - return this.taggedEvents().find(({ marker }) => marker === 'root'); + rootEvent(): MarkedEventTag | undefined { + return this.markedEventTags().find(({ marker }) => marker === 'root'); }, - mentionedEvents(): TaggedEvent[] { - return this.taggedEvents().filter(({ marker }) => marker === 'mention'); + mentionedEvents(): MarkedEventTag[] { + return this.markedEventTags().filter(({ marker }) => marker === 'mention'); }, mentionedPubkeys(): string[] { - return uniq(event.tags.filter(([tagName]) => tagName === 'p').map((e) => e[1])); + return uniq(this.pTags().map(([, pubkey]) => pubkey)); }, mentionedPubkeysWithoutAuthor(): string[] { return this.mentionedPubkeys().filter((pubkey) => pubkey !== event.pubkey); diff --git a/src/nostr/parseTextNote.test.ts b/src/nostr/parseTextNote.test.ts index 81fb3b6..1fd9c97 100644 --- a/src/nostr/parseTextNote.test.ts +++ b/src/nostr/parseTextNote.test.ts @@ -231,7 +231,7 @@ describe('resolveTagReference', () => { content: '', tags: [ ['p', '80d3a41d8a00679c0105faac2cdf7643c9ba26835cff096bf7f9c7a0eee8c8fc'], - ['e', 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f212', '', 'reply'], + ['e', 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f2', '', 'reply'], ], created_at: 1678377182, pubkey: '9366708117c4a7edf9178acdce538c95059b9eb3394808cdd90564094172d972', @@ -242,7 +242,7 @@ describe('resolveTagReference', () => { tagIndex: 1, marker: 'reply', content: '#[1]', - eventId: 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f212', + eventId: 'b9cefcb857fa487d5794156e85b30a7f98cb21721040631210262091d86ff6f2', }; assert.deepStrictEqual(result, expected); }); diff --git a/src/nostr/parseTextNote.ts b/src/nostr/parseTextNote.ts index 95dab68..da5ffa3 100644 --- a/src/nostr/parseTextNote.ts +++ b/src/nostr/parseTextNote.ts @@ -1,6 +1,6 @@ import { nip19, type Event as NostrEvent } from 'nostr-tools'; -import eventWrapper from '@/nostr/event'; +import eventWrapper, { isValidId } from '@/nostr/event'; type ProfilePointer = nip19.ProfilePointer; type EventPointer = nip19.EventPointer; @@ -125,7 +125,7 @@ const parseTextNote = (textNoteContent: string) => { }; result.push(bech32Entity); } catch (e) { - console.warn(`failed to parse Bech32 entity (NIP-19): ${match[0]}`); + console.warn(`ignored invalid bech32 entity: ${match[0]}`); pushPlainText(index + match[0].length); } } else if (match.groups?.hashtag) { @@ -157,7 +157,7 @@ export const resolveTagReference = ( const tagName = tag[0]; - if (tagName === 'p') { + if (tagName === 'p' && isValidId(tag[1])) { return { type: 'MentionedUser', tagIndex, @@ -166,9 +166,9 @@ export const resolveTagReference = ( } satisfies MentionedUser; } - if (tagName === 'e') { + if (tagName === 'e' && isValidId(tag[1])) { const mention = eventWrapper(event) - .taggedEvents() + .markedEventTags() .find((ev) => ev.index === tagIndex); return { diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index 9b2887a..6edad3f 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -237,24 +237,23 @@ const { exec } = useBatch(() => ({ const registeredTasks = textNoteTasks.get(event.id) ?? []; resolveTasks(registeredTasks, event); } else if (event.kind === Kind.Reaction) { - const eventTags = eventWrapper(event).taggedEvents(); - eventTags.forEach((eventTag) => { - const taggedEventId = eventTag.id; - const registeredTasks = reactionsTasks.get(taggedEventId) ?? []; + // Use the last event id + const id = eventWrapper(event).lastTaggedEventId(); + if (id != null) { + const registeredTasks = reactionsTasks.get(id) ?? []; resolveTasks(registeredTasks, event); - }); + } } else if ((event.kind as number) === 6) { - const eventTags = eventWrapper(event).taggedEvents(); - eventTags.forEach((eventTag) => { - const taggedEventId = eventTag.id; - const registeredTasks = repostsTasks.get(taggedEventId) ?? []; + // Use the last event id + const id = eventWrapper(event).lastTaggedEventId(); + if (id != null) { + const registeredTasks = repostsTasks.get(id) ?? []; resolveTasks(registeredTasks, event); - }); + } } else if (event.kind === Kind.Zap) { - const eventTags = eventWrapper(event).taggedEvents(); - eventTags.forEach((eventTag) => { - const taggedEventId = eventTag.id; - const registeredTasks = repostsTasks.get(taggedEventId) ?? []; + const eTags = eventWrapper(event).eTags(); + eTags.forEach(([, id]) => { + const registeredTasks = repostsTasks.get(id) ?? []; resolveTasks(registeredTasks, event); }); } else if (event.kind === Kind.Contacts) { @@ -298,7 +297,7 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf try { queryClient.setQueryData(queryKey, latestEvent()); } catch (err) { - console.error('updating profile error: ', err); + console.error('error occurred while updating profile cache: ', err); } }); return latestEvent(); @@ -472,7 +471,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U try { queryClient.setQueryData(queryKey, latestEvent()); } catch (err) { - console.error('updating followings error: ', err); + console.error('error occurred while updating followings cache: ', err); } }); return latestEvent(); @@ -491,14 +490,12 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U const followings = () => { if (query.data == null) return []; - const event = query.data; - const result: Following[] = []; - event.tags.forEach((tag) => { - // TODO zodにする - const [tagName, followingPubkey, mainRelayUrl, petname] = tag; - if (!tag.every((e) => typeof e === 'string')) return; - if (tagName !== 'p') return; + + // TODO zodにする + const event = eventWrapper(query.data); + event.pTags().forEach((tag) => { + const [, followingPubkey, mainRelayUrl, petname] = tag; const following: Following = { pubkey: followingPubkey, petname }; if (mainRelayUrl != null && mainRelayUrl.length > 0) { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 964eb16..08c6c76 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,4 +1,4 @@ -import { createEffect, onMount, type Component } from 'solid-js'; +import { createEffect, onMount, type Component, onError } from 'solid-js'; import { useNavigate } from '@solidjs/router'; @@ -44,6 +44,10 @@ const Home: Component = () => { } }); + onError((err) => { + console.error('uncaught error: ', err); + }); + return (