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",
"@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",

View File

@@ -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",

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 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<NotePostFormProps> = (props) => {
};
}
publishTextNoteMutation.mutate(textNote);
close();
};
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 { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig();
@@ -295,6 +335,7 @@ const ConfigUI = (props: ConfigProps) => {
<ProfileSection />
<RelayConfig />
<DateFormatConfig />
<EmojiConfig />
<OtherConfig />
<MuteConfig />
</div>

View File

@@ -267,9 +267,14 @@ const ProfileDisplay: Component<ProfileDisplayProps> = (props) => {
</button>
</ContextMenu>
</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>
</Show>
</Match>
</Switch>
</div>
</div>
<div class="flex items-start px-4 pt-2">

View File

@@ -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})$`;

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 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<TextNoteDisplayProps> = (props) => {
const {
reactions,
reactionsGroupedByContent,
isReactedBy,
invalidateReactions,
query: reactionsQuery,
@@ -212,9 +214,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
});
};
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
const doReaction = (emoji?: string) => {
if (isReactedByMe()) {
// TODO remove reaction
return;
@@ -224,7 +224,7 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
publishReactionMutation.mutate({
relayUrls: config().relayUrls,
pubkey: pubkeyNonNull,
content: '+',
content: emoji ?? '+',
eventId: eventIdNonNull,
notifyPubkey: props.event.pubkey,
});
@@ -232,6 +232,11 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
});
};
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.stopPropagation();
doReaction();
};
onMount(() => {
if (contentRef != null) {
setOverflow(contentRef.scrollHeight > contentRef.clientHeight);
@@ -249,7 +254,6 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
}}
>
<Show when={author()?.picture}>
{/* TODO 画像は脆弱性回避のためにimgじゃない方法で読み込みたい */}
<img src={author()?.picture} alt="icon" class="h-full w-full rounded object-cover" />
</Show>
</button>
@@ -262,7 +266,6 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
showProfile(event().pubkey);
}}
>
{/* TODO link to author */}
<Show when={(author()?.display_name?.length ?? 0) > 0}>
<div class="author-name truncate pr-1 font-bold hover:underline">
{author()?.display_name}
@@ -345,6 +348,42 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
</button>
</Show>
<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">
<button
class="h-4 w-4 shrink-0 text-zinc-400"
@@ -379,6 +418,16 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
'text-zinc-400': !isReactedByMe(),
'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
class="h-4 w-4"
@@ -389,7 +438,12 @@ const TextNoteDisplay: Component<TextNoteDisplayProps> = (props) => {
<HeartSolid />
</Show>
</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>
</Show>
</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 = {
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<PopupProps> = (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<PopupProps> = (props) => {
createEffect(() => {
if (isOpen()) {
addClickOutsideHandler();
props.onOpen?.();
} else {
removeClickOutsideHandler();
props.onClose?.();
}
});
createEffect(() => {
if (isOpen()) props.onOpen?.();
});
createEffect(() => {
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') {
popupRef.style.left = `${buttonRect.left - buttonRect.width}px`;
popupRef.style.top = `${buttonRect.top}px`;
left -= buttonRect.width;
} else if (props.position === 'right') {
popupRef.style.left = `${buttonRect.left + buttonRect.width}px`;
popupRef.style.top = `${buttonRect.top}px`;
left += buttonRect.width;
} else if (props.position === 'top') {
popupRef.style.left = `${buttonRect.left + buttonRect.width}px`;
popupRef.style.top = `${buttonRect.top - buttonRect.height}px`;
top -= buttonRect.height;
left -= buttonRect.left + buttonRect.width / 2;
} else {
popupRef.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
popupRef.style.top = `${buttonRect.top + buttonRect.height}px`;
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<PopupProps> = (props) => {
return (
<div>
<button ref={buttonRef} onClick={handleClick}>
<button ref={buttonRef} class="flex items-center" onClick={handleClick}>
{props.button}
</button>
<div ref={popupRef} class="absolute z-20" classList={{ hidden: !isOpen(), block: isOpen() }}>
{props.children}
{resolvedChildren()}
</div>
</div>
);

View File

@@ -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',
];

View File

@@ -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: [],

View File

@@ -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;
}

View File

@@ -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;
}
});