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={() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user