feat: emoji reaction

This commit is contained in:
Shusui MOYATANI
2023-05-12 21:05:31 +09:00
parent c3a657e8db
commit 9253bb9554
13 changed files with 261 additions and 50 deletions

11
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@tanstack/solid-virtual": "^3.0.0-beta.6", "@tanstack/solid-virtual": "^3.0.0-beta.6",
"@thisbeyond/solid-dnd": "^0.7.4", "@thisbeyond/solid-dnd": "^0.7.4",
"@types/lodash": "^4.14.194", "@types/lodash": "^4.14.194",
"emoji-mart": "^5.5.2",
"heroicons": "^2.0.17", "heroicons": "^2.0.17",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nostr-tools": "^1.10.1", "nostr-tools": "^1.10.1",
@@ -2699,6 +2700,11 @@
"integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==",
"dev": true "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": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -9181,6 +9187,11 @@
"integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==",
"dev": true "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": { "emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",

View File

@@ -54,6 +54,7 @@
"@tanstack/solid-virtual": "^3.0.0-beta.6", "@tanstack/solid-virtual": "^3.0.0-beta.6",
"@thisbeyond/solid-dnd": "^0.7.4", "@thisbeyond/solid-dnd": "^0.7.4",
"@types/lodash": "^4.14.194", "@types/lodash": "^4.14.194",
"emoji-mart": "^5.5.2",
"heroicons": "^2.0.17", "heroicons": "^2.0.17",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nostr-tools": "^1.10.1", "nostr-tools": "^1.10.1",

View File

@@ -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<EmojiPickerProps> = (props) => {
let popupRef: PopupRef | undefined;
const [pickerElement, setPickerElement] = createSignal<HTMLElement | undefined>(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 (
<Popup
ref={(e) => {
popupRef = e;
}}
position="bottom"
button={props.children}
onOpen={handleOpen}
onClose={handleClose}
>
{pickerElement()}
</Popup>
);
};
export default EmojiPicker;

View File

@@ -72,7 +72,7 @@ const extract = (parsed: ParsedTextNote) => {
}; };
const format = (parsed: ParsedTextNote) => { const format = (parsed: ParsedTextNote) => {
const content = []; const content: string[] = [];
parsed.forEach((node) => { parsed.forEach((node) => {
if (node.type === 'Bech32Entity' && !node.isNIP19) { if (node.type === 'Bech32Entity' && !node.isNIP19) {
content.push(`nostr:${node.content}`); content.push(`nostr:${node.content}`);
@@ -205,6 +205,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
}; };
} }
publishTextNoteMutation.mutate(textNote); publishTextNoteMutation.mutate(textNote);
close();
}; };
const handleInput: JSX.EventHandler<HTMLTextAreaElement, InputEvent> = (ev) => { const handleInput: JSX.EventHandler<HTMLTextAreaElement, InputEvent> = (ev) => {

View File

@@ -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 (
<div class="py-2">
<h3 class="font-bold"></h3>
<div class="flex flex-col justify-evenly gap-2">
<div class="flex w-full">
<div class="flex-1"></div>
<ToggleButton
value={config().useEmojiReaction}
onClick={() => toggleUseEmojiReaction()}
/>
</div>
<div class="flex w-full">
<div class="flex-1">稿</div>
<ToggleButton
value={config().showEmojiReaction}
onClick={() => toggleShowEmojiReaction()}
/>
</div>
</div>
</div>
);
};
const MuteConfig = () => { const MuteConfig = () => {
const { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig(); const { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig();
@@ -295,6 +335,7 @@ const ConfigUI = (props: ConfigProps) => {
<ProfileSection /> <ProfileSection />
<RelayConfig /> <RelayConfig />
<DateFormatConfig /> <DateFormatConfig />
<EmojiConfig />
<OtherConfig /> <OtherConfig />
<MuteConfig /> <MuteConfig />
</div> </div>

View File

@@ -267,9 +267,14 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
</button> </button>
</ContextMenu> </ContextMenu>
</div> </div>
<Show when={followed()}> <Switch>
<Match when={userFollowingQuery.isLoading}>
<div class="shrink-0 text-xs"></div>
</Match>
<Match when={followed()}>
<div class="shrink-0 text-xs"></div> <div class="shrink-0 text-xs"></div>
</Show> </Match>
</Switch>
</div> </div>
</div> </div>
<div class="flex items-start px-4 pt-2"> <div class="flex items-start px-4 pt-2">

View File

@@ -17,7 +17,7 @@ export type ProfileEditProps = {
onClose: () => void; 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 InternetIdentiferRegexString = '[-_a-zA-Z0-9.]+@[-a-zA-Z0-9.]+';
const LUDAddressRegexString = `^(${LNURLRegexString}|${InternetIdentiferRegexString})$`; const LUDAddressRegexString = `^(${LNURLRegexString}|${InternetIdentiferRegexString})$`;

View File

@@ -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 ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg'; import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import HeartOutlined from 'heroicons/24/outline/heart.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 HeartSolid from 'heroicons/24/solid/heart.svg';
import { nip19, type Event as NostrEvent } from 'nostr-tools'; import { nip19, type Event as NostrEvent } from 'nostr-tools';
import ContextMenu, { MenuItem } from '@/components/ContextMenu'; import ContextMenu, { MenuItem } from '@/components/ContextMenu';
import EmojiPicker from '@/components/EmojiPicker';
import NotePostForm from '@/components/NotePostForm'; import NotePostForm from '@/components/NotePostForm';
import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay'; import ContentWarningDisplay from '@/components/textNote/ContentWarningDisplay';
import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay'; import GeneralUserMentionDisplay from '@/components/textNote/GeneralUserMentionDisplay';
@@ -25,7 +27,6 @@ import useProfile from '@/nostr/useProfile';
import usePubkey from '@/nostr/usePubkey'; import usePubkey from '@/nostr/usePubkey';
import useReactions from '@/nostr/useReactions'; import useReactions from '@/nostr/useReactions';
import useReposts from '@/nostr/useReposts'; import useReposts from '@/nostr/useReposts';
import useSubscription from '@/nostr/useSubscription';
import ensureNonNull from '@/utils/ensureNonNull'; import ensureNonNull from '@/utils/ensureNonNull';
import npubEncodeFallback from '@/utils/npubEncodeFallback'; import npubEncodeFallback from '@/utils/npubEncodeFallback';
import timeout from '@/utils/timeout'; import timeout from '@/utils/timeout';
@@ -65,6 +66,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
const { const {
reactions, reactions,
reactionsGroupedByContent,
isReactedBy, isReactedBy,
invalidateReactions, invalidateReactions,
query: reactionsQuery, query: reactionsQuery,
@@ -212,9 +214,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
}); });
}; };
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => { const doReaction = (emoji?: string) => {
ev.stopPropagation();
if (isReactedByMe()) { if (isReactedByMe()) {
// TODO remove reaction // TODO remove reaction
return; return;
@@ -224,7 +224,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
publishReactionMutation.mutate({ publishReactionMutation.mutate({
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
pubkey: pubkeyNonNull, pubkey: pubkeyNonNull,
content: '+', content: emoji ?? '+',
eventId: eventIdNonNull, eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey, notifyPubkey: props.event.pubkey,
}); });
@@ -232,6 +232,11 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
}); });
}; };
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
doReaction();
};
onMount(() => { onMount(() => {
if (contentRef != null) { if (contentRef != null) {
setOverflow(contentRef.scrollHeight > contentRef.clientHeight); setOverflow(contentRef.scrollHeight > contentRef.clientHeight);
@@ -249,7 +254,6 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
}} }}
> >
<Show when={author()?.picture}> <Show when={author()?.picture}>
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
<img src={author()?.picture} alt="icon" class="h-full w-full rounded object-cover" /> <img src={author()?.picture} alt="icon" class="h-full w-full rounded object-cover" />
</Show> </Show>
</button> </button>
@@ -262,7 +266,6 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
showProfile(event().pubkey); showProfile(event().pubkey);
}} }}
> >
{/* TODO link to author */}
<Show when={(author()?.display_name?.length ?? 0) > 0}> <Show when={(author()?.display_name?.length ?? 0) > 0}>
<div class="author-name truncate pr-1 font-bold hover:underline"> <div class="author-name truncate pr-1 font-bold hover:underline">
{author()?.display_name} {author()?.display_name}
@@ -345,6 +348,42 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
</button> </button>
</Show> </Show>
<Show when={actions()}> <Show when={actions()}>
<Show when={config().showEmojiReaction && reactions().length > 0}>
<div class="flex gap-2 pt-1">
<For each={[...reactionsGroupedByContent().entries()]}>
{([content, events]) => {
const isReactedByMeWithThisContent =
events.findIndex((ev) => ev.pubkey === pubkey()) >= 0;
return (
<button
class="flex items-center rounded border px-1"
classList={{
'text-zinc-400': !isReactedByMeWithThisContent,
'bg-rose-50': isReactedByMeWithThisContent,
'border-rose-200': isReactedByMeWithThisContent,
'text-rose-400': isReactedByMeWithThisContent,
}}
type="button"
onClick={() => doReaction(content)}
>
<Show
when={content === '+'}
fallback={<span class="text-xs">{content}</span>}
>
<span class="inline-block h-3 w-3 pt-[1px] text-rose-400">
<HeartSolid />
</span>
</Show>
<Show when={!config().hideCount}>
<span class="ml-1 text-sm">{events.length}</span>
</Show>
</button>
);
}}
</For>
</div>
</Show>
<div class="actions 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 shrink-0 text-zinc-400" class="h-4 w-4 shrink-0 text-zinc-400"
@@ -379,6 +418,16 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
'text-zinc-400': !isReactedByMe(), 'text-zinc-400': !isReactedByMe(),
'text-rose-400': isReactedByMe() || publishReactionMutation.isLoading, 'text-rose-400': isReactedByMe() || publishReactionMutation.isLoading,
}} }}
>
<Show
when={!config().useEmojiReaction}
fallback={
<EmojiPicker onEmojiSelect={(emoji) => doReaction(emoji)}>
<span class="inline-block h-4 w-4">
<Plus />
</span>
</EmojiPicker>
}
> >
<button <button
class="h-4 w-4" class="h-4 w-4"
@@ -389,7 +438,12 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<HeartSolid /> <HeartSolid />
</Show> </Show>
</button> </button>
<Show when={!config().hideCount && reactions().length > 0}> </Show>
<Show
when={
!config().hideCount && !config().showEmojiReaction && reactions().length > 0
}
>
<div class="text-sm text-zinc-400">{reactions().length}</div> <div class="text-sm text-zinc-400">{reactions().length}</div>
</Show> </Show>
</div> </div>

View File

@@ -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 = { export type PopupRef = {
close: () => void; close: () => void;
@@ -9,6 +18,7 @@ export type PopupProps = {
button: JSX.Element; button: JSX.Element;
position?: 'left' | 'bottom' | 'right' | 'top'; position?: 'left' | 'bottom' | 'right' | 'top';
onOpen?: () => void; onOpen?: () => void;
onClose?: () => void;
ref?: (ref: PopupRef) => void; ref?: (ref: PopupRef) => void;
}; };
@@ -17,6 +27,7 @@ const Popup: Component<PopupProps> = (props) => {
let popupRef: HTMLDivElement | undefined; let popupRef: HTMLDivElement | undefined;
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
const resolvedChildren = children(() => props.children);
const handleClickOutside = (ev: MouseEvent) => { const handleClickOutside = (ev: MouseEvent) => {
const target = ev.target as HTMLElement; const target = ev.target as HTMLElement;
@@ -40,34 +51,42 @@ const Popup: Component<PopupProps> = (props) => {
createEffect(() => { createEffect(() => {
if (isOpen()) { if (isOpen()) {
addClickOutsideHandler(); addClickOutsideHandler();
props.onOpen?.();
} else { } else {
removeClickOutsideHandler(); removeClickOutsideHandler();
props.onClose?.();
} }
}); });
createEffect(() => { createEffect(
if (isOpen()) props.onOpen?.(); on(resolvedChildren, () => {
});
createEffect(() => {
if (buttonRef == null || popupRef == null) return; if (buttonRef == null || popupRef == null) return;
const buttonRect = buttonRef?.getBoundingClientRect(); const buttonRect = buttonRef?.getBoundingClientRect();
const popupRect = popupRef?.getBoundingClientRect();
let { top, left } = buttonRect;
if (props.position === 'left') { if (props.position === 'left') {
popupRef.style.left = `${buttonRect.left - buttonRect.width}px`; left -= buttonRect.width;
popupRef.style.top = `${buttonRect.top}px`;
} else if (props.position === 'right') { } else if (props.position === 'right') {
popupRef.style.left = `${buttonRect.left + buttonRect.width}px`; left += buttonRect.width;
popupRef.style.top = `${buttonRect.top}px`;
} else if (props.position === 'top') { } else if (props.position === 'top') {
popupRef.style.left = `${buttonRect.left + buttonRect.width}px`; top -= buttonRect.height;
popupRef.style.top = `${buttonRect.top - buttonRect.height}px`; left -= buttonRect.left + buttonRect.width / 2;
} else { } else {
popupRef.style.left = `${buttonRect.left + buttonRect.width / 2}px`; top += buttonRect.height;
popupRef.style.top = `${buttonRect.top + buttonRect.height}px`; 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(() => { onMount(() => {
props.ref?.({ close }); props.ref?.({ close });
@@ -77,11 +96,11 @@ const Popup: Component<PopupProps> = (props) => {
return ( return (
<div> <div>
<button ref={buttonRef} onClick={handleClick}> <button ref={buttonRef} class="flex items-center" onClick={handleClick}>
{props.button} {props.button}
</button> </button>
<div ref={popupRef} class="absolute z-20" classList={{ hidden: !isOpen(), block: isOpen() }}> <div ref={popupRef} class="absolute z-20" classList={{ hidden: !isOpen(), block: isOpen() }}>
{props.children} {resolvedChildren()}
</div> </div>
</div> </div>
); );

View File

@@ -6,7 +6,7 @@ export const relaysGlobal: string[] = [
export const relaysOnlyAvailableInJP: string[] = [ export const relaysOnlyAvailableInJP: string[] = [
'wss://relay-jp.nostr.wirednet.jp', 'wss://relay-jp.nostr.wirednet.jp',
'wss://nostr.h3z.jp', // 'wss://nostr.h3z.jp',
'wss://nostr.holybea.com', 'wss://nostr.holybea.com',
]; ];

View File

@@ -22,6 +22,8 @@ export type Config = {
columns: ColumnType[]; columns: ColumnType[];
dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; dateFormat: 'relative' | 'absolute-long' | 'absolute-short';
keepOpenPostForm: boolean; keepOpenPostForm: boolean;
useEmojiReaction: boolean;
showEmojiReaction: boolean;
showImage: boolean; showImage: boolean;
hideCount: boolean; hideCount: boolean;
mutedPubkeys: string[]; mutedPubkeys: string[];
@@ -62,6 +64,8 @@ const InitialConfig = (): Config => ({
columns: [], columns: [],
dateFormat: 'relative', dateFormat: 'relative',
keepOpenPostForm: false, keepOpenPostForm: false,
useEmojiReaction: false,
showEmojiReaction: false,
showImage: true, showImage: true,
hideCount: false, hideCount: false,
mutedPubkeys: [], mutedPubkeys: [],

View File

@@ -16,10 +16,27 @@ code {
monospace; monospace;
} }
.navigationButton {
@apply inline-block py-4 px-1 text-xl font-bold text-blue-500 hover:text-blue-600;
}
.link { .link {
@apply underline text-blue-500; @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;
}

View File

@@ -323,7 +323,7 @@ export const useProfile = (propsProvider: () => UseProfileProps | null): UseProf
try { try {
return JSON.parse(content) as Profile; return JSON.parse(content) as Profile;
} catch (err) { } catch (err) {
console.error('failed to parse profile (kind 0): ', err, content); console.warn('failed to parse profile (kind 0): ', err, content);
return null; return null;
} }
}); });