diff --git a/package-lock.json b/package-lock.json index 9010a30..0c32b3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@tanstack/solid-virtual": "^3.0.0-beta.6", "@thisbeyond/solid-dnd": "^0.7.4", "@types/lodash": "^4.14.194", + "emoji-mart": "^5.5.2", "heroicons": "^2.0.17", "lodash": "^4.17.21", "nostr-tools": "^1.10.1", @@ -2699,6 +2700,11 @@ "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", "dev": true }, + "node_modules/emoji-mart": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.5.2.tgz", + "integrity": "sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -9181,6 +9187,11 @@ "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", "dev": true }, + "emoji-mart": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.5.2.tgz", + "integrity": "sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==" + }, "emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/package.json b/package.json index 2e307e2..f702c64 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@tanstack/solid-virtual": "^3.0.0-beta.6", "@thisbeyond/solid-dnd": "^0.7.4", "@types/lodash": "^4.14.194", + "emoji-mart": "^5.5.2", "heroicons": "^2.0.17", "lodash": "^4.17.21", "nostr-tools": "^1.10.1", diff --git a/src/components/EmojiPicker.tsx b/src/components/EmojiPicker.tsx new file mode 100644 index 0000000..b582a5b --- /dev/null +++ b/src/components/EmojiPicker.tsx @@ -0,0 +1,58 @@ +import { Component, JSX, createSignal } from 'solid-js'; + +import { Picker } from 'emoji-mart'; + +import Popup, { PopupRef } from '@/components/utils/Popup'; + +type EmojiPickerProps = { + onEmojiSelect?: (emoji: string) => void; + children: JSX.Element; +}; + +const EmojiPicker: Component = (props) => { + let popupRef: PopupRef | undefined; + + const [pickerElement, setPickerElement] = createSignal(undefined); + + const handleOpen = () => { + const picker = new Picker({ + data: async () => { + const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data'); + return response.json(); + }, + i18n: async () => { + const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data/i18n/ja.json'); + return response.json(); + }, + autoFocus: true, + locale: 'ja', + theme: 'light', + onEmojiSelect: (emoji: { id: string; native: string }) => { + props.onEmojiSelect?.(emoji.native); + popupRef?.close(); + }, + }); + + setPickerElement(picker as any as HTMLElement); + }; + + const handleClose = () => { + setPickerElement(undefined); + }; + + return ( + { + popupRef = e; + }} + position="bottom" + button={props.children} + onOpen={handleOpen} + onClose={handleClose} + > + {pickerElement()} + + ); +}; + +export default EmojiPicker; diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index 17f0a6c..f74c4e3 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -72,7 +72,7 @@ const extract = (parsed: ParsedTextNote) => { }; const format = (parsed: ParsedTextNote) => { - const content = []; + const content: string[] = []; parsed.forEach((node) => { if (node.type === 'Bech32Entity' && !node.isNIP19) { content.push(`nostr:${node.content}`); @@ -205,6 +205,7 @@ const NotePostForm: Component = (props) => { }; } publishTextNoteMutation.mutate(textNote); + close(); }; const handleInput: JSX.EventHandler = (ev) => { diff --git a/src/components/modal/Config.tsx b/src/components/modal/Config.tsx index 50f6d85..e47947a 100644 --- a/src/components/modal/Config.tsx +++ b/src/components/modal/Config.tsx @@ -166,6 +166,46 @@ const ToggleButton = (props: { ); }; +const EmojiConfig = () => { + const { config, setConfig } = useConfig(); + + const toggleUseEmojiReaction = () => { + setConfig((current) => ({ + ...current, + useEmojiReaction: !(current.useEmojiReaction ?? false), + })); + }; + + const toggleShowEmojiReaction = () => { + setConfig((current) => ({ + ...current, + showEmojiReaction: !(current.showEmojiReaction ?? false), + })); + }; + + return ( +
+

リアクション

+
+
+
絵文字を選べるようにする
+ toggleUseEmojiReaction()} + /> +
+
+
投稿にリアクションされた絵文字を表示する
+ toggleShowEmojiReaction()} + /> +
+
+
+ ); +}; + const MuteConfig = () => { const { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig(); @@ -295,6 +335,7 @@ const ConfigUI = (props: ConfigProps) => { + diff --git a/src/components/modal/ProfileDisplay.tsx b/src/components/modal/ProfileDisplay.tsx index 22f9c2b..d6cef82 100644 --- a/src/components/modal/ProfileDisplay.tsx +++ b/src/components/modal/ProfileDisplay.tsx @@ -267,9 +267,14 @@ const ProfileDisplay: Component = (props) => { - -
フォローされています
-
+ + +
読み込み中
+
+ +
フォローされています
+
+
diff --git a/src/components/modal/ProfileEdit.tsx b/src/components/modal/ProfileEdit.tsx index e2647fa..aafabc3 100644 --- a/src/components/modal/ProfileEdit.tsx +++ b/src/components/modal/ProfileEdit.tsx @@ -17,7 +17,7 @@ export type ProfileEditProps = { onClose: () => void; }; -const LNURLRegexString = 'LNURL1[AC-HJ-NP-Zac-hj-np-z02-9]+'; +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})$`; diff --git a/src/components/textNote/TextNoteDisplay.tsx b/src/components/textNote/TextNoteDisplay.tsx index 915a034..086912c 100644 --- a/src/components/textNote/TextNoteDisplay.tsx +++ b/src/components/textNote/TextNoteDisplay.tsx @@ -5,10 +5,12 @@ import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-squa import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg'; import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg'; import HeartOutlined from 'heroicons/24/outline/heart.svg'; +import Plus from 'heroicons/24/outline/plus.svg'; import HeartSolid from 'heroicons/24/solid/heart.svg'; import { nip19, type Event as NostrEvent } from 'nostr-tools'; import ContextMenu, { MenuItem } from '@/components/ContextMenu'; +import EmojiPicker from '@/components/EmojiPicker'; import NotePostForm from '@/components/NotePostForm'; import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay'; import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay'; @@ -25,7 +27,6 @@ import useProfile from '@/nostr/useProfile'; import usePubkey from '@/nostr/usePubkey'; import useReactions from '@/nostr/useReactions'; import useReposts from '@/nostr/useReposts'; -import useSubscription from '@/nostr/useSubscription'; import ensureNonNull from '@/utils/ensureNonNull'; import npubEncodeFallback from '@/utils/npubEncodeFallback'; import timeout from '@/utils/timeout'; @@ -65,6 +66,7 @@ const TextNoteDisplay: Component = (props) => { const { reactions, + reactionsGroupedByContent, isReactedBy, invalidateReactions, query: reactionsQuery, @@ -212,9 +214,7 @@ const TextNoteDisplay: Component = (props) => { }); }; - const handleReaction: JSX.EventHandler = (ev) => { - ev.stopPropagation(); - + const doReaction = (emoji?: string) => { if (isReactedByMe()) { // TODO remove reaction return; @@ -224,7 +224,7 @@ const TextNoteDisplay: Component = (props) => { publishReactionMutation.mutate({ relayUrls: config().relayUrls, pubkey: pubkeyNonNull, - content: '+', + content: emoji ?? '+', eventId: eventIdNonNull, notifyPubkey: props.event.pubkey, }); @@ -232,6 +232,11 @@ const TextNoteDisplay: Component = (props) => { }); }; + const handleReaction: JSX.EventHandler = (ev) => { + ev.stopPropagation(); + doReaction(); + }; + onMount(() => { if (contentRef != null) { setOverflow(contentRef.scrollHeight > contentRef.clientHeight); @@ -249,7 +254,6 @@ const TextNoteDisplay: Component = (props) => { }} > - {/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */} icon @@ -262,7 +266,6 @@ const TextNoteDisplay: Component = (props) => { showProfile(event().pubkey); }} > - {/* TODO link to author */} 0}>
{author()?.display_name} @@ -345,6 +348,42 @@ const TextNoteDisplay: Component = (props) => { + 0}> +
+ + {([content, events]) => { + const isReactedByMeWithThisContent = + events.findIndex((ev) => ev.pubkey === pubkey()) >= 0; + + return ( + + ); + }} + +
+
+ + 0 + } > - }> - - - - 0}>
{reactions().length}
diff --git a/src/components/utils/Popup.tsx b/src/components/utils/Popup.tsx index c085ea7..fb50a3e 100644 --- a/src/components/utils/Popup.tsx +++ b/src/components/utils/Popup.tsx @@ -1,4 +1,13 @@ -import { createSignal, createEffect, type Component, type JSX, onCleanup, onMount } from 'solid-js'; +import { + createSignal, + createEffect, + type Component, + type JSX, + on, + onCleanup, + onMount, + children, +} from 'solid-js'; export type PopupRef = { close: () => void; @@ -9,6 +18,7 @@ export type PopupProps = { button: JSX.Element; position?: 'left' | 'bottom' | 'right' | 'top'; onOpen?: () => void; + onClose?: () => void; ref?: (ref: PopupRef) => void; }; @@ -17,6 +27,7 @@ const Popup: Component = (props) => { let popupRef: HTMLDivElement | undefined; const [isOpen, setIsOpen] = createSignal(false); + const resolvedChildren = children(() => props.children); const handleClickOutside = (ev: MouseEvent) => { const target = ev.target as HTMLElement; @@ -40,34 +51,42 @@ const Popup: Component = (props) => { createEffect(() => { if (isOpen()) { addClickOutsideHandler(); + props.onOpen?.(); } else { removeClickOutsideHandler(); + props.onClose?.(); } }); - createEffect(() => { - if (isOpen()) props.onOpen?.(); - }); + createEffect( + on(resolvedChildren, () => { + if (buttonRef == null || popupRef == null) return; - createEffect(() => { - if (buttonRef == null || popupRef == null) return; + const buttonRect = buttonRef?.getBoundingClientRect(); + const popupRect = popupRef?.getBoundingClientRect(); - const buttonRect = buttonRef?.getBoundingClientRect(); + let { top, left } = buttonRect; - if (props.position === 'left') { - popupRef.style.left = `${buttonRect.left - buttonRect.width}px`; - popupRef.style.top = `${buttonRect.top}px`; - } else if (props.position === 'right') { - popupRef.style.left = `${buttonRect.left + buttonRect.width}px`; - popupRef.style.top = `${buttonRect.top}px`; - } else if (props.position === 'top') { - popupRef.style.left = `${buttonRect.left + buttonRect.width}px`; - popupRef.style.top = `${buttonRect.top - buttonRect.height}px`; - } else { - popupRef.style.left = `${buttonRect.left + buttonRect.width / 2}px`; - popupRef.style.top = `${buttonRect.top + buttonRect.height}px`; - } - }); + 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`; + }), + ); onMount(() => { props.ref?.({ close }); @@ -77,11 +96,11 @@ const Popup: Component = (props) => { return (
-
- {props.children} + {resolvedChildren()}
); diff --git a/src/core/relayUrls.ts b/src/core/relayUrls.ts index dcb5598..1faf0bf 100644 --- a/src/core/relayUrls.ts +++ b/src/core/relayUrls.ts @@ -6,7 +6,7 @@ export const relaysGlobal: string[] = [ export const relaysOnlyAvailableInJP: string[] = [ 'wss://relay-jp.nostr.wirednet.jp', - 'wss://nostr.h3z.jp', + // 'wss://nostr.h3z.jp', 'wss://nostr.holybea.com', ]; diff --git a/src/core/useConfig.ts b/src/core/useConfig.ts index db84eb0..88ce5ea 100644 --- a/src/core/useConfig.ts +++ b/src/core/useConfig.ts @@ -22,6 +22,8 @@ export type Config = { columns: ColumnType[]; dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; keepOpenPostForm: boolean; + useEmojiReaction: boolean; + showEmojiReaction: boolean; showImage: boolean; hideCount: boolean; mutedPubkeys: string[]; @@ -62,6 +64,8 @@ const InitialConfig = (): Config => ({ columns: [], dateFormat: 'relative', keepOpenPostForm: false, + useEmojiReaction: false, + showEmojiReaction: false, showImage: true, hideCount: false, mutedPubkeys: [], diff --git a/src/index.css b/src/index.css index 28f59af..54fe48c 100644 --- a/src/index.css +++ b/src/index.css @@ -16,10 +16,27 @@ code { monospace; } -.navigationButton { - @apply inline-block py-4 px-1 text-xl font-bold text-blue-500 hover:text-blue-600; -} - .link { @apply underline text-blue-500; } + +em-emoji-picker { + --background-rgb: 85, 170, 255; + --border-radius: 8px; + --color-border-over: rgba(0, 0, 0, 0.1); + --color-border: rgba(0, 0, 0, 0.05); + --category-icon-size: 20px; + --font-size: 16px; + --rgb-accent: 253, 164, 175; + --rgb-background: 255, 255, 255; + --rgb-color: 28, 25, 23; + --rgb-input: 255, 255, 255; + --shadow: 0 5px 8px -8px #222; + + border: 1px solid rgba(0, 0, 0, 0.1); + height: 50vh; + min-height: 400px; + max-height: 800px; + width: 360px; + max-width: 90vw; +} diff --git a/src/nostr/useBatchedEvents.ts b/src/nostr/useBatchedEvents.ts index dc1847e..9b2887a 100644 --- a/src/nostr/useBatchedEvents.ts +++ b/src/nostr/useBatchedEvents.ts @@ -323,7 +323,7 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf try { return JSON.parse(content) as Profile; } catch (err) { - console.error('failed to parse profile (kind 0): ', err, content); + console.warn('failed to parse profile (kind 0): ', err, content); return null; } });