feat: support posting custom emoji

This commit is contained in:
Shusui MOYATANI
2023-05-16 23:59:00 +09:00
parent 12e38f85d9
commit ee6731023e
6 changed files with 171 additions and 38 deletions

View File

@@ -3,17 +3,34 @@ import { Component, JSX, createSignal } from 'solid-js';
import { Picker } from 'emoji-mart'; import { Picker } from 'emoji-mart';
import Popup, { PopupRef } from '@/components/utils/Popup'; import Popup, { PopupRef } from '@/components/utils/Popup';
import useConfig from '@/core/useConfig';
type EmojiPickerProps = { type EmojiPickerProps = {
onEmojiSelect?: (emoji: string) => void; onEmojiSelect?: (emoji: string) => void;
customEmojis?: boolean;
children: JSX.Element; children: JSX.Element;
}; };
const EmojiPicker: Component<EmojiPickerProps> = (props) => { const EmojiPicker: Component<EmojiPickerProps> = (props) => {
let popupRef: PopupRef | undefined; let popupRef: PopupRef | undefined;
const { config } = useConfig();
const [pickerElement, setPickerElement] = createSignal<HTMLElement | undefined>(undefined); const [pickerElement, setPickerElement] = createSignal<HTMLElement | undefined>(undefined);
/*
const buildCustom = () => {
const emojis = Object.values(config().customEmojis).map(({ shortcode, url }) => ({
id: shortcode,
name: shortcode,
keywords: [shortcode],
skins: [{ src: url }],
}));
console.log(emojis);
return [{ id: 'custom_rabbit', name: 'カスタム絵文字', emojis }];
};
*/
const handleOpen = () => { const handleOpen = () => {
const picker = new Picker({ const picker = new Picker({
data: async () => { data: async () => {
@@ -24,11 +41,12 @@ const EmojiPicker: Component<EmojiPickerProps> = (props) => {
const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data/i18n/ja.json'); const response = await fetch('https://cdn.jsdelivr.net/npm/@emoji-mart/data/i18n/ja.json');
return response.json(); return response.json();
}, },
// custom: props.customEmojis ? buildCustom() : [],
autoFocus: false, autoFocus: false,
locale: 'ja', locale: 'ja',
theme: 'light', theme: 'light',
onEmojiSelect: (emoji: { id: string; native: string }) => { onEmojiSelect: (emoji: { id: string; native?: string }) => {
props.onEmojiSelect?.(emoji.native); props.onEmojiSelect?.(emoji.native ?? `:${emoji.id}:`);
popupRef?.close(); popupRef?.close();
}, },
}); });

View File

@@ -1,21 +1,14 @@
import { import { createSignal, createMemo, onMount, Show, For, type Component, type JSX } from 'solid-js';
createSignal,
createMemo,
onMount,
Show,
For,
type Component,
type JSX,
type Accessor,
} from 'solid-js';
import { createMutation } from '@tanstack/solid-query'; import { createMutation } from '@tanstack/solid-query';
import FaceSmile from 'heroicons/24/outline/face-smile.svg';
import Photo from 'heroicons/24/outline/photo.svg'; import Photo from 'heroicons/24/outline/photo.svg';
import XMark from 'heroicons/24/outline/x-mark.svg'; import XMark from 'heroicons/24/outline/x-mark.svg';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg'; import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import { Event as NostrEvent } from 'nostr-tools'; import { Event as NostrEvent } from 'nostr-tools';
import EmojiPicker from '@/components/EmojiPicker';
import UserNameDisplay from '@/components/UserDisplayName'; import UserNameDisplay from '@/components/UserDisplayName';
import useConfig from '@/core/useConfig'; import useConfig from '@/core/useConfig';
import { useHandleCommand } from '@/hooks/useCommandBus'; import { useHandleCommand } from '@/hooks/useCommandBus';
@@ -48,12 +41,13 @@ const extract = (parsed: ParsedTextNote) => {
const pubkeyReferences: string[] = []; const pubkeyReferences: string[] = [];
const eventReferences: string[] = []; const eventReferences: string[] = [];
const urlReferences: string[] = []; const urlReferences: string[] = [];
const emojis: string[] = [];
parsed.forEach((node) => { parsed.forEach((node) => {
if (node.type === 'HashTag') { if (node.type === 'URL') {
hashtags.push(node.tagName);
} else if (node.type === 'URL') {
urlReferences.push(node.content); urlReferences.push(node.content);
} else if (node.type === 'HashTag') {
hashtags.push(node.tagName);
} else if (node.type === 'Bech32Entity') { } else if (node.type === 'Bech32Entity') {
if (node.data.type === 'npub') { if (node.data.type === 'npub') {
pubkeyReferences.push(node.data.data); pubkeyReferences.push(node.data.data);
@@ -63,14 +57,17 @@ const extract = (parsed: ParsedTextNote) => {
// TODO nevent can contain an event not only textnote (kind:1). // TODO nevent can contain an event not only textnote (kind:1).
// In my understanding, it is not allowed to include other kinds of events in `tags`. // In my understanding, it is not allowed to include other kinds of events in `tags`.
// It is needed to verify that the kind of the event is 1. // It is needed to verify that the kind of the event is 1.
} else if (node.type === 'CustomEmoji' && !emojis.includes(node.shortcode)) {
emojis.push(node.shortcode);
} }
}); });
return { return {
hashtags, hashtags,
urlReferences,
pubkeyReferences, pubkeyReferences,
eventReferences, eventReferences,
urlReferences, emojis,
}; };
}; };
@@ -94,6 +91,8 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const [contentWarning, setContentWarning] = createSignal(false); const [contentWarning, setContentWarning] = createSignal(false);
const [contentWarningReason, setContentWarningReason] = createSignal(''); const [contentWarningReason, setContentWarningReason] = createSignal('');
const appendText = (s: string) => setText((current) => `${current} ${s}`);
const clearText = () => { const clearText = () => {
setText(''); setText('');
setContentWarningReason(''); setContentWarningReason('');
@@ -106,7 +105,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
props.onClose(); props.onClose();
}; };
const { config } = useConfig(); const { config, getEmoji } = useConfig();
const getPubkey = usePubkey(); const getPubkey = usePubkey();
const commands = useCommands(); const commands = useCommands();
@@ -140,7 +139,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
uploadResults.forEach((result) => { uploadResults.forEach((result) => {
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
console.log('succeeded to upload', result); console.log('succeeded to upload', result);
setText((current) => `${current} ${result.value.imageUrl}`); appendText(result.value.imageUrl);
resizeTextArea(); resizeTextArea();
} else { } else {
console.error('failed to upload', result); console.error('failed to upload', result);
@@ -169,6 +168,19 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
]); ]);
}; };
const buildEmojiTags = (emojis: string[]): string[][] => {
const emojiTags: string[][] = [];
emojis.forEach((shortcode) => {
const emoji = getEmoji(shortcode);
if (emoji != null) {
emojiTags.push(['emoji', shortcode, emoji.url]);
}
});
return emojiTags;
};
const submit = () => { const submit = () => {
if (text().length === 0) return; if (text().length === 0) return;
if (publishTextNoteMutation.isLoading) return; if (publishTextNoteMutation.isLoading) return;
@@ -180,8 +192,9 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
} }
const parsed = parseTextNote(text()); const parsed = parseTextNote(text());
const { hashtags, pubkeyReferences, eventReferences, urlReferences } = extract(parsed); const { hashtags, urlReferences, pubkeyReferences, eventReferences, emojis } = extract(parsed);
const formattedContent = format(parsed); const formattedContent = format(parsed);
const emojiTags = buildEmojiTags(emojis);
let textNote: PublishTextNoteParams = { let textNote: PublishTextNoteParams = {
relayUrls: config().relayUrls, relayUrls: config().relayUrls,
@@ -191,6 +204,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
mentionEventIds: eventReferences, mentionEventIds: eventReferences,
hashtags, hashtags,
urls: urlReferences, urls: urlReferences,
tags: emojiTags,
}; };
if (replyTo() != null) { if (replyTo() != null) {
@@ -329,6 +343,13 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
</button> </button>
</div> </div>
</Show> </Show>
{/*
<EmojiPicker customEmojis={true} onEmojiSelect={(emoji) => appendText(emoji)}>
<span class="inline-block h-8 w-8 rounded bg-primary p-2 font-bold text-white">
<FaceSmile />
</span>
</EmojiPicker>
*/}
<button <button
class="flex items-center justify-center rounded p-2 text-xs font-bold text-white" class="flex items-center justify-center rounded p-2 text-xs font-bold text-white"
classList={{ classList={{

View File

@@ -13,6 +13,11 @@ type ConfigProps = {
onClose: () => void; onClose: () => void;
}; };
const BaseUrlRegex = (schemaRegex: string) =>
`^(?:${schemaRegex})://[-a-zA-Z0-9.]+(:\\d{1,5})?(?:/[-[\\]~!$&'()*+.,:;@&=%\\w]+|/)*(?:\\?[-[\\]~!$&'()*+.,/:;%@&=\\w?]+)?(?:#[-[\\]~!$&'()*+.,/:;%@\\w&=?#]+)?$`;
const HttpUrlRegex = BaseUrlRegex('https?');
const RelayUrlRegex = BaseUrlRegex('wss?');
const ProfileSection = () => { const ProfileSection = () => {
const pubkey = usePubkey(); const pubkey = usePubkey();
const { showProfile, showProfileEdit } = useModalState(); const { showProfile, showProfileEdit } = useModalState();
@@ -77,6 +82,7 @@ const RelayConfig = () => {
type="text" type="text"
name="relayUrl" name="relayUrl"
value={relayUrlInput()} value={relayUrlInput()}
pattern={RelayUrlRegex}
onChange={(ev) => setRelayUrlInput(ev.currentTarget.value)} onChange={(ev) => setRelayUrlInput(ev.currentTarget.value)}
/> />
<button type="submit" class="rounded bg-rose-300 p-2 font-bold text-white"> <button type="submit" class="rounded bg-rose-300 p-2 font-bold text-white">
@@ -166,7 +172,7 @@ const ToggleButton = (props: {
); );
}; };
const EmojiConfig = () => { const ReactionConfig = () => {
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const toggleUseEmojiReaction = () => { const toggleUseEmojiReaction = () => {
@@ -206,6 +212,68 @@ const EmojiConfig = () => {
); );
}; };
const EmojiConfig = () => {
const { config, saveEmoji, removeEmoji } = useConfig();
const [shortcodeInput, setShortcodeInput] = createSignal('');
const [urlInput, setUrlInput] = createSignal('');
const handleClickSaveEmoji: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (ev) => {
ev.preventDefault();
if (shortcodeInput().length === 0 || urlInput().length === 0) return;
saveEmoji({ shortcode: shortcodeInput(), url: urlInput() });
};
return (
<div class="py-2">
<h3 class="font-bold"></h3>
<ul class="flex flex-col gap-1 py-2">
<For each={Object.values(config().customEmojis)}>
{({ shortcode, url }) => (
<li class="flex items-center gap-2">
<img class="h-7 max-w-[128px]" src={url} alt={shortcode} />
<div class="flex-1 truncate">{shortcode}</div>
<button class="h-3 w-3 shrink-0" onClick={() => removeEmoji(shortcode)}>
<XMark />
</button>
</li>
)}
</For>
</ul>
<form class="flex gap-2" onSubmit={handleClickSaveEmoji}>
<label class="flex flex-1 items-center gap-1">
<div></div>
<input
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="shortcode"
value={shortcodeInput()}
pattern="^[a-zA-Z0-9]+$"
required
onChange={(ev) => setShortcodeInput(ev.currentTarget.value)}
/>
</label>
<label class="flex flex-1 items-center gap-1">
<div>URL</div>
<input
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
type="text"
name="url"
value={urlInput()}
placeholder="https://.../emoji.png"
pattern={HttpUrlRegex}
required
onChange={(ev) => setUrlInput(ev.currentTarget.value)}
/>
</label>
<button type="submit" class="rounded bg-rose-300 p-2 font-bold text-white">
</button>
</form>
</div>
);
};
const MuteConfig = () => { const MuteConfig = () => {
const { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig(); const { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig();
@@ -335,6 +403,7 @@ const ConfigUI = (props: ConfigProps) => {
<ProfileSection /> <ProfileSection />
<RelayConfig /> <RelayConfig />
<DateFormatConfig /> <DateFormatConfig />
<ReactionConfig />
<EmojiConfig /> <EmojiConfig />
<OtherConfig /> <OtherConfig />
<MuteConfig /> <MuteConfig />

View File

@@ -104,6 +104,7 @@ const Popup: Component<PopupProps> = (props) => {
return ( return (
<div> <div>
<button <button
type="button"
ref={buttonRef} ref={buttonRef}
class="flex items-center" class="flex items-center"
onClick={() => { onClick={() => {

View File

@@ -1,6 +1,7 @@
import { type Accessor, type Setter } from 'solid-js'; import { type Accessor, type Setter } from 'solid-js';
import uniq from 'lodash/uniq'; import uniq from 'lodash/uniq';
import uniqBy from 'lodash/uniqBy';
import { Kind, type Event as NostrEvent } from 'nostr-tools'; import { Kind, type Event as NostrEvent } from 'nostr-tools';
import { import {
@@ -18,9 +19,15 @@ import {
} from '@/hooks/createSignalWithStorage'; } from '@/hooks/createSignalWithStorage';
import eventWrapper from '@/nostr/event'; import eventWrapper from '@/nostr/event';
export type CustomEmojiConfig = {
shortcode: string;
url: string;
};
export type Config = { export type Config = {
relayUrls: string[]; relayUrls: string[];
columns: ColumnType[]; columns: ColumnType[];
customEmojis: Record<string, CustomEmojiConfig>;
dateFormat: 'relative' | 'absolute-long' | 'absolute-short'; dateFormat: 'relative' | 'absolute-long' | 'absolute-short';
keepOpenPostForm: boolean; keepOpenPostForm: boolean;
useEmojiReaction: boolean; useEmojiReaction: boolean;
@@ -37,19 +44,22 @@ type UseConfig = {
// relay // relay
addRelay: (url: string) => void; addRelay: (url: string) => void;
removeRelay: (url: string) => void; removeRelay: (url: string) => void;
// column
saveColumn: (column: ColumnType) => void;
moveColumn: (columnId: string, index: number) => void;
removeColumn: (columnId: string) => void;
initializeColumns: (param: { pubkey: string }) => void;
// emoji
saveEmoji: (emoji: CustomEmojiConfig) => void;
removeEmoji: (shortcode: string) => void;
getEmoji: (shortcode: string) => CustomEmojiConfig | undefined;
// mute // mute
addMutedPubkey: (pubkey: string) => void; addMutedPubkey: (pubkey: string) => void;
removeMutedPubkey: (pubkey: string) => void; removeMutedPubkey: (pubkey: string) => void;
addMutedKeyword: (keyword: string) => void; addMutedKeyword: (keyword: string) => void;
removeMutedKeyword: (keyword: string) => void; removeMutedKeyword: (keyword: string) => void;
// column
saveColumn: (column: ColumnType) => void;
moveColumn: (columnId: string, index: number) => void;
removeColumn: (columnId: string) => void;
// functions
isPubkeyMuted: (pubkey: string) => boolean; isPubkeyMuted: (pubkey: string) => boolean;
shouldMuteEvent: (event: NostrEvent) => boolean; shouldMuteEvent: (event: NostrEvent) => boolean;
initializeColumns: (param: { pubkey: string }) => void;
}; };
const initialRelays = (): string[] => { const initialRelays = (): string[] => {
@@ -63,6 +73,7 @@ const initialRelays = (): string[] => {
const InitialConfig = (): Config => ({ const InitialConfig = (): Config => ({
relayUrls: initialRelays(), relayUrls: initialRelays(),
columns: [], columns: [],
customEmojis: {},
dateFormat: 'relative', dateFormat: 'relative',
keepOpenPostForm: false, keepOpenPostForm: false,
useEmojiReaction: false, useEmojiReaction: false,
@@ -141,6 +152,17 @@ const useConfig = (): UseConfig => {
setConfig('columns', (current) => current.filter((e) => e.id !== columnId)); setConfig('columns', (current) => current.filter((e) => e.id !== columnId));
}; };
const saveEmoji = (emoji: CustomEmojiConfig) => {
setConfig('customEmojis', (current) => ({ ...current, [emoji.shortcode]: emoji }));
};
const removeEmoji = (shortcode: string) => {
setConfig('customEmojis', (current) => ({ ...current, [shortcode]: undefined }));
};
const getEmoji = (shortcode: string): CustomEmojiConfig | undefined =>
config.customEmojis[shortcode];
const isPubkeyMuted = (pubkey: string) => config.mutedPubkeys.includes(pubkey); const isPubkeyMuted = (pubkey: string) => config.mutedPubkeys.includes(pubkey);
const hasMutedKeyword = (event: NostrEvent) => { const hasMutedKeyword = (event: NostrEvent) => {
@@ -180,18 +202,25 @@ const useConfig = (): UseConfig => {
return { return {
config: () => config, config: () => config,
setConfig, setConfig,
// relay
addRelay, addRelay,
removeRelay, removeRelay,
// column
saveColumn,
moveColumn,
removeColumn,
initializeColumns,
// emoji
saveEmoji,
removeEmoji,
getEmoji,
// mute
addMutedPubkey, addMutedPubkey,
removeMutedPubkey, removeMutedPubkey,
addMutedKeyword, addMutedKeyword,
removeMutedKeyword, removeMutedKeyword,
saveColumn,
moveColumn,
removeColumn,
isPubkeyMuted, isPubkeyMuted,
shouldMuteEvent, shouldMuteEvent,
initializeColumns,
}; };
}; };

View File

@@ -47,20 +47,15 @@ const eventSchema = z.object({
const EmojiTagSchema = z.tuple([ const EmojiTagSchema = z.tuple([
z.literal('emoji'), z.literal('emoji'),
z.string().regex(/^[a-zA-Z0-9]+$/, { message: 'shortcode should be alpahnumeric' }), z.string().regex(/^[a-zA-Z0-9]+$/, { message: 'shortcode should be alpahnumeric' }),
z.string().url().refine(isImageUrl), z.string().url(), // .refine(isImageUrl)
]); ]);
export type EmojiTag = z.infer<typeof EmojiTagSchema>; export type EmojiTag = z.infer<typeof EmojiTagSchema>;
const ensureSchema = const ensureSchema =
<T>(schema: z.Schema<T>) => <T>(schema: z.Schema<T>) =>
(value: any): value is T => { (value: any): value is T =>
const result = schema.safeParse(value); schema.safeParse(value).success;
if (!result.success) {
console.warn('failed to parse value', value, schema);
}
return result.success;
};
const eventWrapper = (event: NostrEvent) => { const eventWrapper = (event: NostrEvent) => {
let memoizedMarkedEventTags: MarkedEventTag[] | undefined; let memoizedMarkedEventTags: MarkedEventTag[] | undefined;