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 Popup, { PopupRef } from '@/components/utils/Popup';
import useConfig from '@/core/useConfig';
type EmojiPickerProps = {
onEmojiSelect?: (emoji: string) => void;
customEmojis?: boolean;
children: JSX.Element;
};
const EmojiPicker: Component<EmojiPickerProps> = (props) => {
let popupRef: PopupRef | undefined;
const { config } = useConfig();
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 picker = new Picker({
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');
return response.json();
},
// custom: props.customEmojis ? buildCustom() : [],
autoFocus: false,
locale: 'ja',
theme: 'light',
onEmojiSelect: (emoji: { id: string; native: string }) => {
props.onEmojiSelect?.(emoji.native);
onEmojiSelect: (emoji: { id: string; native?: string }) => {
props.onEmojiSelect?.(emoji.native ?? `:${emoji.id}:`);
popupRef?.close();
},
});

View File

@@ -1,21 +1,14 @@
import {
createSignal,
createMemo,
onMount,
Show,
For,
type Component,
type JSX,
type Accessor,
} from 'solid-js';
import { createSignal, createMemo, onMount, Show, For, type Component, type JSX } from 'solid-js';
import { createMutation } from '@tanstack/solid-query';
import FaceSmile from 'heroicons/24/outline/face-smile.svg';
import Photo from 'heroicons/24/outline/photo.svg';
import XMark from 'heroicons/24/outline/x-mark.svg';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
import uniq from 'lodash/uniq';
import { Event as NostrEvent } from 'nostr-tools';
import EmojiPicker from '@/components/EmojiPicker';
import UserNameDisplay from '@/components/UserDisplayName';
import useConfig from '@/core/useConfig';
import { useHandleCommand } from '@/hooks/useCommandBus';
@@ -48,12 +41,13 @@ const extract = (parsed: ParsedTextNote) => {
const pubkeyReferences: string[] = [];
const eventReferences: string[] = [];
const urlReferences: string[] = [];
const emojis: string[] = [];
parsed.forEach((node) => {
if (node.type === 'HashTag') {
hashtags.push(node.tagName);
} else if (node.type === 'URL') {
if (node.type === 'URL') {
urlReferences.push(node.content);
} else if (node.type === 'HashTag') {
hashtags.push(node.tagName);
} else if (node.type === 'Bech32Entity') {
if (node.data.type === 'npub') {
pubkeyReferences.push(node.data.data);
@@ -63,14 +57,17 @@ const extract = (parsed: ParsedTextNote) => {
// 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`.
// 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 {
hashtags,
urlReferences,
pubkeyReferences,
eventReferences,
urlReferences,
emojis,
};
};
@@ -94,6 +91,8 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
const [contentWarning, setContentWarning] = createSignal(false);
const [contentWarningReason, setContentWarningReason] = createSignal('');
const appendText = (s: string) => setText((current) => `${current} ${s}`);
const clearText = () => {
setText('');
setContentWarningReason('');
@@ -106,7 +105,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
props.onClose();
};
const { config } = useConfig();
const { config, getEmoji } = useConfig();
const getPubkey = usePubkey();
const commands = useCommands();
@@ -140,7 +139,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
uploadResults.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('succeeded to upload', result);
setText((current) => `${current} ${result.value.imageUrl}`);
appendText(result.value.imageUrl);
resizeTextArea();
} else {
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 = () => {
if (text().length === 0) return;
if (publishTextNoteMutation.isLoading) return;
@@ -180,8 +192,9 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
}
const parsed = parseTextNote(text());
const { hashtags, pubkeyReferences, eventReferences, urlReferences } = extract(parsed);
const { hashtags, urlReferences, pubkeyReferences, eventReferences, emojis } = extract(parsed);
const formattedContent = format(parsed);
const emojiTags = buildEmojiTags(emojis);
let textNote: PublishTextNoteParams = {
relayUrls: config().relayUrls,
@@ -191,6 +204,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
mentionEventIds: eventReferences,
hashtags,
urls: urlReferences,
tags: emojiTags,
};
if (replyTo() != null) {
@@ -329,6 +343,13 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
</button>
</div>
</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
class="flex items-center justify-center rounded p-2 text-xs font-bold text-white"
classList={{

View File

@@ -13,6 +13,11 @@ type ConfigProps = {
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 pubkey = usePubkey();
const { showProfile, showProfileEdit } = useModalState();
@@ -77,6 +82,7 @@ const RelayConfig = () => {
type="text"
name="relayUrl"
value={relayUrlInput()}
pattern={RelayUrlRegex}
onChange={(ev) => setRelayUrlInput(ev.currentTarget.value)}
/>
<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 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 { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig();
@@ -335,6 +403,7 @@ const ConfigUI = (props: ConfigProps) => {
<ProfileSection />
<RelayConfig />
<DateFormatConfig />
<ReactionConfig />
<EmojiConfig />
<OtherConfig />
<MuteConfig />

View File

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

View File

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

View File

@@ -47,20 +47,15 @@ const eventSchema = z.object({
const EmojiTagSchema = z.tuple([
z.literal('emoji'),
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>;
const ensureSchema =
<T>(schema: z.Schema<T>) =>
(value: any): value is T => {
const result = schema.safeParse(value);
if (!result.success) {
console.warn('failed to parse value', value, schema);
}
return result.success;
};
(value: any): value is T =>
schema.safeParse(value).success;
const eventWrapper = (event: NostrEvent) => {
let memoizedMarkedEventTags: MarkedEventTag[] | undefined;