mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 14:34:25 +01:00
feat: support posting custom emoji
This commit is contained in:
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -104,6 +104,7 @@ const Popup: Component<PopupProps> = (props) => {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
class="flex items-center"
|
||||
onClick={() => {
|
||||
|
||||
Reference in New Issue
Block a user