mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
update
This commit is contained in:
@@ -136,20 +136,23 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
|
|
||||||
const uploadFilesMutation = createMutation({
|
const uploadFilesMutation = createMutation({
|
||||||
mutationKey: ['uploadFiles'],
|
mutationKey: ['uploadFiles'],
|
||||||
mutationFn: (files: File[]) => {
|
mutationFn: async (files: File[]) => {
|
||||||
return uploadFiles(uploadNostrBuild)(files)
|
const uploadResults = await uploadFiles(uploadNostrBuild)(files);
|
||||||
.then((uploadResults) => {
|
const failed: File[] = [];
|
||||||
uploadResults.forEach((result) => {
|
|
||||||
if (result.status === 'fulfilled') {
|
uploadResults.forEach((result, i) => {
|
||||||
console.log('succeeded to upload', result);
|
if (result.status === 'fulfilled') {
|
||||||
appendText(result.value.imageUrl);
|
appendText(result.value.imageUrl);
|
||||||
resizeTextArea();
|
resizeTextArea();
|
||||||
} else {
|
} else {
|
||||||
console.error('failed to upload', result);
|
failed.push(files[i]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.catch((err) => console.error(err));
|
if (failed.length > 0) {
|
||||||
|
const filenames = failed.map((f) => f.name).join(', ');
|
||||||
|
window.alert(`ファイルのアップロードに失敗しました: ${filenames}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,7 +221,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
...notifyPubkeys(),
|
...notifyPubkeys(),
|
||||||
...pubkeyReferences, // 本文中の公開鍵(npub)
|
...pubkeyReferences, // 本文中の公開鍵(npub)
|
||||||
]),
|
]),
|
||||||
rootEventId: replyTo()?.rootEvent()?.id ?? replyTo()?.id,
|
rootEventId: replyTo()?.rootEvent()?.id,
|
||||||
replyEventId: replyTo()?.id,
|
replyEventId: replyTo()?.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -232,22 +235,6 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureUploaderAgreement = (): boolean => {
|
|
||||||
if (didAgreeToToS('nostrBuild')) return true;
|
|
||||||
|
|
||||||
window.alert(
|
|
||||||
'画像アップローダーの利用規約をお読みください。\n(新しいタブで利用規約を開きます)',
|
|
||||||
);
|
|
||||||
openLink(uploaders.nostrBuild.tos);
|
|
||||||
const didAgree = window.confirm('同意する場合はOKをクリックしてください。');
|
|
||||||
|
|
||||||
if (didAgree) {
|
|
||||||
agreeToToS('nostrBuild');
|
|
||||||
}
|
|
||||||
|
|
||||||
return didAgree;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInput: JSX.EventHandler<HTMLTextAreaElement, InputEvent> = (ev) => {
|
const handleInput: JSX.EventHandler<HTMLTextAreaElement, InputEvent> = (ev) => {
|
||||||
setText(ev.currentTarget.value);
|
setText(ev.currentTarget.value);
|
||||||
resizeTextArea();
|
resizeTextArea();
|
||||||
@@ -267,10 +254,29 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureUploaderAgreement = (): boolean => {
|
||||||
|
return true;
|
||||||
|
/*
|
||||||
|
if (didAgreeToToS('nostrBuild')) return true;
|
||||||
|
|
||||||
|
window.alert(
|
||||||
|
'画像アップローダーの利用規約をお読みください。\n(新しいタブで利用規約を開きます)',
|
||||||
|
);
|
||||||
|
openLink(uploaders.nostrBuild.tos);
|
||||||
|
const didAgree = window.confirm('同意する場合はOKをクリックしてください。');
|
||||||
|
|
||||||
|
if (didAgree) {
|
||||||
|
agreeToToS('nostrBuild');
|
||||||
|
}
|
||||||
|
|
||||||
|
return didAgree;
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangeFile: JSX.EventHandler<HTMLInputElement, Event> = (ev) => {
|
const handleChangeFile: JSX.EventHandler<HTMLInputElement, Event> = (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (uploadFilesMutation.isLoading) return;
|
if (uploadFilesMutation.isLoading) return;
|
||||||
if (!ensureUploaderAgreement()) return;
|
// if (!ensureUploaderAgreement()) return;
|
||||||
|
|
||||||
const files = [...(ev.currentTarget.files ?? [])];
|
const files = [...(ev.currentTarget.files ?? [])];
|
||||||
uploadFilesMutation.mutate(files);
|
uploadFilesMutation.mutate(files);
|
||||||
@@ -281,7 +287,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
const handleDrop: JSX.EventHandler<HTMLTextAreaElement, DragEvent> = (ev) => {
|
const handleDrop: JSX.EventHandler<HTMLTextAreaElement, DragEvent> = (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (uploadFilesMutation.isLoading) return;
|
if (uploadFilesMutation.isLoading) return;
|
||||||
if (!ensureUploaderAgreement()) return;
|
// if (!ensureUploaderAgreement()) return;
|
||||||
const files = [...(ev?.dataTransfer?.files ?? [])];
|
const files = [...(ev?.dataTransfer?.files ?? [])];
|
||||||
uploadFilesMutation.mutate(files);
|
uploadFilesMutation.mutate(files);
|
||||||
};
|
};
|
||||||
@@ -301,7 +307,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
if (!ensureUploaderAgreement()) return;
|
// if (!ensureUploaderAgreement()) return;
|
||||||
|
|
||||||
uploadFilesMutation.mutate(files);
|
uploadFilesMutation.mutate(files);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const SearchButton = () => {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
saveColumn(createSearchColumn({ query: query() }));
|
saveColumn(createSearchColumn({ query: query() }));
|
||||||
request({ command: 'moveToLastColumn' }).catch((err) => console.log(err));
|
request({ command: 'moveToLastColumn' }).catch((err) => console.error(err));
|
||||||
popupRef?.close();
|
popupRef?.close();
|
||||||
setQuery('');
|
setQuery('');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ const Column: Component<ColumnProps> = (props) => {
|
|||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<div class="shrink-0 border-b">{props.header}</div>
|
<div class="shrink-0 border-b">{props.header}</div>
|
||||||
<div class="flex flex-col overflow-y-scroll scroll-smooth">{props.children}</div>
|
<div class="scrollbar flex flex-col overflow-y-scroll scroll-smooth">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -74,7 +76,7 @@ const Column: Component<ColumnProps> = (props) => {
|
|||||||
<div>ホームに戻る</div>
|
<div>ホームに戻る</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="flex h-full flex-col overflow-y-scroll scroll-smooth">
|
<ul class="scrollbar flex h-full flex-col overflow-y-scroll scroll-smooth">
|
||||||
<TimelineContentDisplay timelineContent={timeline} />
|
<TimelineContentDisplay timelineContent={timeline} />
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const Columns = () => {
|
|||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full snap-x snap-mandatory flex-row overflow-y-hidden overflow-x-scroll">
|
<div class="scrollbar flex h-full snap-x snap-mandatory flex-row overflow-y-hidden overflow-x-scroll">
|
||||||
<For each={config().columns}>
|
<For each={config().columns}>
|
||||||
{(column, index) => {
|
{(column, index) => {
|
||||||
const columnIndex = () => index() + 1;
|
const columnIndex = () => index() + 1;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const FollowingColumn: Component<FollowingColumnDisplayProps> = (props) => {
|
|||||||
const authors = uniq([...followingPubkeys()]);
|
const authors = uniq([...followingPubkeys()]);
|
||||||
if (authors.length === 0) return null;
|
if (authors.length === 0) return null;
|
||||||
return {
|
return {
|
||||||
|
debugId: 'following',
|
||||||
relayUrls: config().relayUrls,
|
relayUrls: config().relayUrls,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,38 +2,15 @@ import { Component, Show } from 'solid-js';
|
|||||||
|
|
||||||
import ChatBubbleLeftRight from 'heroicons/24/outline/chat-bubble-left-right.svg';
|
import ChatBubbleLeftRight from 'heroicons/24/outline/chat-bubble-left-right.svg';
|
||||||
import { Event as NostrEvent } from 'nostr-tools';
|
import { Event as NostrEvent } from 'nostr-tools';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import EventLink from '@/components/EventLink';
|
import { parseChannelMeta } from '@/nostr/event/channel';
|
||||||
import { isImageUrl } from '@/utils/imageUrl';
|
|
||||||
|
|
||||||
export type ChannelInfoProps = {
|
export type ChannelInfoProps = {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChannelMetaSchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
about: z.string().optional(),
|
|
||||||
picture: z
|
|
||||||
.string()
|
|
||||||
.url()
|
|
||||||
.refine((url) => isImageUrl(url), { message: 'not an image url' })
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ChannelMeta = z.infer<typeof ChannelMetaSchema>;
|
|
||||||
|
|
||||||
const parseContent = (content: string): ChannelMeta | null => {
|
|
||||||
try {
|
|
||||||
return ChannelMetaSchema.parse(JSON.parse(content));
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('failed to parse chat channel schema: ', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ChannelInfo: Component<ChannelInfoProps> = (props) => {
|
const ChannelInfo: Component<ChannelInfoProps> = (props) => {
|
||||||
const parsedContent = () => parseContent(props.event.content);
|
const parsedContent = () => parseChannelMeta(props.event.content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={parsedContent()} keyed>
|
<Show when={parsedContent()} keyed>
|
||||||
37
src/components/event/LongFormContent.tsx
Normal file
37
src/components/event/LongFormContent.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Component } from 'solid-js';
|
||||||
|
|
||||||
|
import DocumentText from 'heroicons/24/outline/document-text.svg';
|
||||||
|
import { Kind, Event as NostrEvent } from 'nostr-tools';
|
||||||
|
|
||||||
|
import eventWrapper from '@/nostr/event';
|
||||||
|
|
||||||
|
export type LongFormContentProps = {
|
||||||
|
event: NostrEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LongFormContent: Component<LongFormContentProps> = (props) => {
|
||||||
|
const event = () => eventWrapper(props.event);
|
||||||
|
const getMeta = (name: string) => {
|
||||||
|
const tags = event().findTagsByName(name);
|
||||||
|
if (tags.length === 0) return null;
|
||||||
|
const [, lastTagValue] = tags[tags.length - 1];
|
||||||
|
return lastTagValue;
|
||||||
|
};
|
||||||
|
const title = () => getMeta('title');
|
||||||
|
// const imageUrl = () => getMeta('image');
|
||||||
|
// const summary = () => getMeta('summary');
|
||||||
|
// const publishdAt = () => getMeta('published_at');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button class="flex flex-col gap-1 px-1">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="inline-block h-4 w-4 text-purple-400">
|
||||||
|
<DocumentText />
|
||||||
|
</span>
|
||||||
|
<span>{title()}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LongFormContent;
|
||||||
@@ -13,6 +13,7 @@ import UserNameDisplay from '@/components/UserDisplayName';
|
|||||||
import useConfig, { type Config } from '@/core/useConfig';
|
import useConfig, { type Config } from '@/core/useConfig';
|
||||||
import useModalState from '@/hooks/useModalState';
|
import useModalState from '@/hooks/useModalState';
|
||||||
import usePubkey from '@/nostr/usePubkey';
|
import usePubkey from '@/nostr/usePubkey';
|
||||||
|
import { simpleEmojiPackSchema, convertToEmojiConfig } from '@/utils/emojipack';
|
||||||
import ensureNonNull from '@/utils/ensureNonNull';
|
import ensureNonNull from '@/utils/ensureNonNull';
|
||||||
|
|
||||||
type ConfigProps = {
|
type ConfigProps = {
|
||||||
@@ -228,6 +229,8 @@ const EmojiConfig = () => {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (shortcodeInput().length === 0 || urlInput().length === 0) return;
|
if (shortcodeInput().length === 0 || urlInput().length === 0) return;
|
||||||
saveEmoji({ shortcode: shortcodeInput(), url: urlInput() });
|
saveEmoji({ shortcode: shortcodeInput(), url: urlInput() });
|
||||||
|
setShortcodeInput('');
|
||||||
|
setUrlInput('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -253,6 +256,7 @@ const EmojiConfig = () => {
|
|||||||
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
|
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
|
||||||
type="text"
|
type="text"
|
||||||
name="shortcode"
|
name="shortcode"
|
||||||
|
placeholder="smiley"
|
||||||
value={shortcodeInput()}
|
value={shortcodeInput()}
|
||||||
pattern="^\\w+$"
|
pattern="^\\w+$"
|
||||||
required
|
required
|
||||||
@@ -266,7 +270,7 @@ const EmojiConfig = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
name="url"
|
name="url"
|
||||||
value={urlInput()}
|
value={urlInput()}
|
||||||
placeholder="https://.../emoji.png"
|
placeholder="https://example.com/smiley.png"
|
||||||
pattern={HttpUrlRegex}
|
pattern={HttpUrlRegex}
|
||||||
required
|
required
|
||||||
onChange={(ev) => setUrlInput(ev.currentTarget.value)}
|
onChange={(ev) => setUrlInput(ev.currentTarget.value)}
|
||||||
@@ -280,6 +284,46 @@ const EmojiConfig = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EmojiImport = () => {
|
||||||
|
const { saveEmojis } = useConfig();
|
||||||
|
|
||||||
|
const [jsonInput, setJSONInput] = createSignal('');
|
||||||
|
|
||||||
|
const handleClickSaveEmoji: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (jsonInput().length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = simpleEmojiPackSchema.parse(JSON.parse(jsonInput()));
|
||||||
|
const emojis = convertToEmojiConfig(data);
|
||||||
|
saveEmojis(emojis);
|
||||||
|
setJSONInput('');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? `:${err.message}` : '';
|
||||||
|
window.alert(`JSONの読み込みに失敗しました${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="py-2">
|
||||||
|
<h3 class="font-bold">絵文字のインポート</h3>
|
||||||
|
<p>絵文字の名前をキー、画像のURLを値とするJSONを読み込むことができます。</p>
|
||||||
|
<form class="flex flex-col gap-2" onSubmit={handleClickSaveEmoji}>
|
||||||
|
<textarea
|
||||||
|
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
|
||||||
|
name="json"
|
||||||
|
value={jsonInput()}
|
||||||
|
placeholder='{ "smiley": "https://example.com/smiley.png" }'
|
||||||
|
onChange={(ev) => setJSONInput(ev.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" class="w-24 self-end 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();
|
||||||
|
|
||||||
@@ -428,7 +472,12 @@ const ConfigUI = (props: ConfigProps) => {
|
|||||||
{
|
{
|
||||||
name: () => 'カスタム絵文字',
|
name: () => 'カスタム絵文字',
|
||||||
icon: () => <FaceSmile />,
|
icon: () => <FaceSmile />,
|
||||||
render: () => <EmojiConfig />,
|
render: () => (
|
||||||
|
<>
|
||||||
|
<EmojiConfig />
|
||||||
|
<EmojiImport />
|
||||||
|
</>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: () => 'ミュート',
|
name: () => 'ミュート',
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export const createJapanRelaysColumn = () =>
|
|||||||
relayUrls: relaysOnlyAvailableInJP,
|
relayUrls: relaysOnlyAvailableInJP,
|
||||||
contentFilter: {
|
contentFilter: {
|
||||||
filterType: 'Regex',
|
filterType: 'Regex',
|
||||||
regex: '[\\p{scx=Hiragana}\\p{scx=Katakana}]',
|
regex: '[\\p{sc=Hiragana}\\p{sc=Katakana}ー]',
|
||||||
flag: 'u',
|
flag: 'u',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,12 +21,14 @@
|
|||||||
*/
|
*/
|
||||||
export type ContentFilterAnd = { filterType: 'AND'; children: ContentFilter[] };
|
export type ContentFilterAnd = { filterType: 'AND'; children: ContentFilter[] };
|
||||||
export type ContentFilterOr = { filterType: 'OR'; children: ContentFilter[] };
|
export type ContentFilterOr = { filterType: 'OR'; children: ContentFilter[] };
|
||||||
|
export type ContentFilterNot = { filterType: 'NOT'; child: ContentFilter };
|
||||||
export type ContentFilterTextInclude = { filterType: 'Text'; text: string };
|
export type ContentFilterTextInclude = { filterType: 'Text'; text: string };
|
||||||
export type ContentFilterRegex = { filterType: 'Regex'; regex: string; flag: string };
|
export type ContentFilterRegex = { filterType: 'Regex'; regex: string; flag: string };
|
||||||
|
|
||||||
export type ContentFilter =
|
export type ContentFilter =
|
||||||
| ContentFilterAnd
|
| ContentFilterAnd
|
||||||
| ContentFilterOr
|
| ContentFilterOr
|
||||||
|
| ContentFilterNot
|
||||||
| ContentFilterTextInclude
|
| ContentFilterTextInclude
|
||||||
| ContentFilterRegex;
|
| ContentFilterRegex;
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ export const applyContentFilter =
|
|||||||
return filter.children.every((child) => applyContentFilter(child)(content));
|
return filter.children.every((child) => applyContentFilter(child)(content));
|
||||||
case 'OR':
|
case 'OR':
|
||||||
return filter.children.some((child) => applyContentFilter(child)(content));
|
return filter.children.some((child) => applyContentFilter(child)(content));
|
||||||
|
case 'NOT':
|
||||||
|
return !applyContentFilter(filter.child)(content);
|
||||||
case 'Text':
|
case 'Text':
|
||||||
return content.includes(filter.text);
|
return content.includes(filter.text);
|
||||||
case 'Regex':
|
case 'Regex':
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type UseConfig = {
|
|||||||
initializeColumns: (param: { pubkey: string }) => void;
|
initializeColumns: (param: { pubkey: string }) => void;
|
||||||
// emoji
|
// emoji
|
||||||
saveEmoji: (emoji: CustomEmojiConfig) => void;
|
saveEmoji: (emoji: CustomEmojiConfig) => void;
|
||||||
|
saveEmojis: (emojis: CustomEmojiConfig[]) => void;
|
||||||
removeEmoji: (shortcode: string) => void;
|
removeEmoji: (shortcode: string) => void;
|
||||||
getEmoji: (shortcode: string) => CustomEmojiConfig | undefined;
|
getEmoji: (shortcode: string) => CustomEmojiConfig | undefined;
|
||||||
// mute
|
// mute
|
||||||
@@ -156,6 +157,13 @@ const useConfig = (): UseConfig => {
|
|||||||
setConfig('customEmojis', (current) => ({ ...current, [emoji.shortcode]: emoji }));
|
setConfig('customEmojis', (current) => ({ ...current, [emoji.shortcode]: emoji }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveEmojis = (emojis: CustomEmojiConfig[]) => {
|
||||||
|
setConfig('customEmojis', (current) => {
|
||||||
|
const newEmojis = Object.fromEntries(emojis.map((emoji) => [emoji.shortcode, emoji]));
|
||||||
|
return { ...current, ...newEmojis };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const removeEmoji = (shortcode: string) => {
|
const removeEmoji = (shortcode: string) => {
|
||||||
setConfig('customEmojis', (current) => ({ ...current, [shortcode]: undefined }));
|
setConfig('customEmojis', (current) => ({ ...current, [shortcode]: undefined }));
|
||||||
};
|
};
|
||||||
@@ -177,7 +185,7 @@ const useConfig = (): UseConfig => {
|
|||||||
return (
|
return (
|
||||||
isPubkeyMuted(event.pubkey) ||
|
isPubkeyMuted(event.pubkey) ||
|
||||||
ev.mentionedPubkeys().some(isPubkeyMuted) ||
|
ev.mentionedPubkeys().some(isPubkeyMuted) ||
|
||||||
hasMutedKeyword(event)
|
(event.kind === Kind.Text && hasMutedKeyword(event))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,6 +220,7 @@ const useConfig = (): UseConfig => {
|
|||||||
initializeColumns,
|
initializeColumns,
|
||||||
// emoji
|
// emoji
|
||||||
saveEmoji,
|
saveEmoji,
|
||||||
|
saveEmojis,
|
||||||
removeEmoji,
|
removeEmoji,
|
||||||
getEmoji,
|
getEmoji,
|
||||||
// mute
|
// mute
|
||||||
|
|||||||
77
src/hooks/useImageAnimation.tsx
Normal file
77
src/hooks/useImageAnimation.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createSignal, onMount, children } from 'solid-js';
|
||||||
|
|
||||||
|
export type UseImageAnimationProps = {
|
||||||
|
initialPlaying?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawImageToCanvas = (image: HTMLImageElement, canvas: HTMLCanvasElement) => {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
canvas.width = image.width;
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
canvas.height = image.height;
|
||||||
|
canvas
|
||||||
|
.getContext('2d')
|
||||||
|
?.drawImage(
|
||||||
|
image,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
image.naturalWidth,
|
||||||
|
image.naturalHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
image.width,
|
||||||
|
image.height,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useImageAnimation = (props: UseImageAnimationProps) => {
|
||||||
|
let canvasRef: HTMLCanvasElement | undefined;
|
||||||
|
let imageRef: HTMLImageElement | undefined;
|
||||||
|
|
||||||
|
const [playing, setPlaying] = createSignal(props?.initialPlaying ?? false);
|
||||||
|
|
||||||
|
const play = () => setPlaying(true);
|
||||||
|
|
||||||
|
const canvas = children(() => (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
class="inline-block"
|
||||||
|
classList={{
|
||||||
|
'w-0': playing(),
|
||||||
|
'h-0': playing(),
|
||||||
|
'w-auto': !playing(),
|
||||||
|
'h-auto': !playing(),
|
||||||
|
}}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
play();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (canvasRef == null || imageRef == null) return;
|
||||||
|
|
||||||
|
drawImageToCanvas(imageRef, canvasRef);
|
||||||
|
imageRef.style.display = 'none';
|
||||||
|
|
||||||
|
setPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (props?.initialPlaying === false) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageRef: (el: HTMLImageElement) => {
|
||||||
|
imageRef = el;
|
||||||
|
},
|
||||||
|
play,
|
||||||
|
stop,
|
||||||
|
canvas,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useImageAnimation;
|
||||||
@@ -4,16 +4,14 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
@@ -40,3 +38,20 @@ em-emoji-picker {
|
|||||||
width: 360px;
|
width: 360px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scrollbar::-webkit-scrollbar:vertical {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.scrollbar::-webkit-scrollbar:horizontal {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
.scrollbar::-webkit-scrollbar {
|
||||||
|
background-color: #fbf9f9;
|
||||||
|
}
|
||||||
|
.scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #fce9ec;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #fcd9dc;
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ const eventWrapper = (event: NostrEvent) => {
|
|||||||
emojiTags(): EmojiTag[] {
|
emojiTags(): EmojiTag[] {
|
||||||
return event.tags.filter(ensureSchema(EmojiTagSchema));
|
return event.tags.filter(ensureSchema(EmojiTagSchema));
|
||||||
},
|
},
|
||||||
|
findTagsByName(name: string): string[][] {
|
||||||
|
return event.tags.filter(([tagName]) => tagName === name);
|
||||||
|
},
|
||||||
taggedEventIds(): string[] {
|
taggedEventIds(): string[] {
|
||||||
return this.eTags().map(([, eventId]) => eventId);
|
return this.eTags().map(([, eventId]) => eventId);
|
||||||
},
|
},
|
||||||
|
|||||||
23
src/nostr/event/channel.tsx
Normal file
23
src/nostr/event/channel.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { isImageUrl } from '@/utils/imageUrl';
|
||||||
|
|
||||||
|
const ChannelMetaSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
about: z.string().optional(),
|
||||||
|
picture: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.refine((url) => isImageUrl(url), { message: 'not an image url' })
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChannelMeta = z.infer<typeof ChannelMetaSchema>;
|
||||||
|
|
||||||
|
export const parseChannelMeta = (content: string): ChannelMeta => {
|
||||||
|
try {
|
||||||
|
return ChannelMetaSchema.parse(JSON.parse(content));
|
||||||
|
} catch (err) {
|
||||||
|
throw new TypeError('failed to parse ChannelMeta schema: ', { cause: err });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -467,6 +467,7 @@ export const useFollowings = (propsProvider: () => UseFollowingsProps | null): U
|
|||||||
const query = createQuery(
|
const query = createQuery(
|
||||||
genQueryKey,
|
genQueryKey,
|
||||||
({ queryKey, signal }) => {
|
({ queryKey, signal }) => {
|
||||||
|
console.debug('useFollowings');
|
||||||
const [, currentProps] = queryKey;
|
const [, currentProps] = queryKey;
|
||||||
if (currentProps == null) return Promise.resolve(null);
|
if (currentProps == null) return Promise.resolve(null);
|
||||||
const { pubkey } = currentProps;
|
const { pubkey } = currentProps;
|
||||||
|
|||||||
@@ -5,10 +5,54 @@ import { describe, it } from 'vitest';
|
|||||||
import { buildTags } from '@/nostr/useCommands';
|
import { buildTags } from '@/nostr/useCommands';
|
||||||
|
|
||||||
describe('buildTags', () => {
|
describe('buildTags', () => {
|
||||||
it('should place a reply tag as first one if it is an only element', () => {
|
it('should return a root tag if only rootEventId is given', () => {
|
||||||
const replyEventId = '6b280916873768d752cb95a0d2787a184926db8b717394c66ae255b221e607a8a';
|
const rootEventId = '9d6f6ae00ede6420fb053c66f06163f5096c8e11c44313cadcc5dd4ddae7f842';
|
||||||
|
const actual = buildTags({ rootEventId });
|
||||||
|
const expect = [['e', rootEventId, '', 'root']];
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual, expect);
|
||||||
|
});
|
||||||
|
|
||||||
|
// For top level replies, only the "root" marker should be used.
|
||||||
|
// https://github.com/nostr-protocol/nips/blob/master/10.md
|
||||||
|
it('should return a root tag if only replyEventId is given', () => {
|
||||||
|
const replyEventId = '9d6f6ae00ede6420fb053c66f06163f5096c8e11c44313cadcc5dd4ddae7f842';
|
||||||
const actual = buildTags({ replyEventId });
|
const actual = buildTags({ replyEventId });
|
||||||
const expect = [['e', replyEventId, '', 'reply']];
|
const expect = [['e', replyEventId, '', 'root']];
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual, expect);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return just a root tag if rootEventId and replyEventId are the same', () => {
|
||||||
|
const eventId = '9d6f6ae00ede6420fb053c66f06163f5096c8e11c44313cadcc5dd4ddae7f842';
|
||||||
|
const actual = buildTags({ rootEventId: eventId, replyEventId: eventId });
|
||||||
|
const expect = [['e', eventId, '', 'root']];
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual, expect);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return root tag and reply tag if rootEventId and replyEventId are different', () => {
|
||||||
|
const rootEventId = '9d6f6ae00ede6420fb053c66f06163f5096c8e11c44313cadcc5dd4ddae7f842';
|
||||||
|
const replyEventId = '750bd0e083d49b36e4d1e25f68b3d9bfa5987c71198e3fe97b955d65acefa5a0';
|
||||||
|
const actual = buildTags({ rootEventId, replyEventId });
|
||||||
|
const expect = [
|
||||||
|
['e', rootEventId, '', 'root'],
|
||||||
|
['e', replyEventId, '', 'reply'],
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual, expect);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return root tag, mention tag and reply tag if rootEventId, mentionEventIds and replyEventId are given', () => {
|
||||||
|
const rootEventId = '9d6f6ae00ede6420fb053c66f06163f5096c8e11c44313cadcc5dd4ddae7f842';
|
||||||
|
const replyEventId = '750bd0e083d49b36e4d1e25f68b3d9bfa5987c71198e3fe97b955d65acefa5a0';
|
||||||
|
const mentionEventIds = ['750bd0e083d49b36e4d1e25f68b3d9bfa5987c71198e3fe97b955d65acefa5a0'];
|
||||||
|
const actual = buildTags({ rootEventId, replyEventId, mentionEventIds });
|
||||||
|
const expect = [
|
||||||
|
['e', rootEventId, '', 'root'],
|
||||||
|
['e', mentionEventIds[0], '', 'mention'],
|
||||||
|
['e', replyEventId, '', 'reply'],
|
||||||
|
];
|
||||||
|
|
||||||
assert.deepStrictEqual(actual, expect);
|
assert.deepStrictEqual(actual, expect);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,10 +55,14 @@ export const buildTags = ({
|
|||||||
if (rootEventId != null) {
|
if (rootEventId != null) {
|
||||||
eTags.push(['e', rootEventId, '', 'root']);
|
eTags.push(['e', rootEventId, '', 'root']);
|
||||||
}
|
}
|
||||||
|
// For top level replies, only the "root" marker should be used.
|
||||||
|
if (rootEventId == null && replyEventId != null) {
|
||||||
|
eTags.push(['e', replyEventId, '', 'root']);
|
||||||
|
}
|
||||||
if (mentionEventIds != null) {
|
if (mentionEventIds != null) {
|
||||||
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
|
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
|
||||||
}
|
}
|
||||||
if (replyEventId != null) {
|
if (rootEventId != null && replyEventId != null && rootEventId !== replyEventId) {
|
||||||
eTags.push(['e', replyEventId, '', 'reply']);
|
eTags.push(['e', replyEventId, '', 'reply']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createSignal, createEffect, onCleanup, on } from 'solid-js';
|
import { createSignal, createMemo, createEffect, onMount, onCleanup, on } from 'solid-js';
|
||||||
|
|
||||||
import uniqBy from 'lodash/uniqBy';
|
import uniqBy from 'lodash/uniqBy';
|
||||||
|
|
||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
import usePool from '@/nostr/usePool';
|
import usePool from '@/nostr/usePool';
|
||||||
import useStats from '@/nostr/useStats';
|
import useStats from '@/nostr/useStats';
|
||||||
|
import epoch from '@/utils/epoch';
|
||||||
|
|
||||||
import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
|
import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export type UseSubscriptionProps = {
|
|||||||
onEvent?: (event: NostrEvent & { id: string }) => void;
|
onEvent?: (event: NostrEvent & { id: string }) => void;
|
||||||
onEOSE?: () => void;
|
onEOSE?: () => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
debugId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortEvents = (events: NostrEvent[]) =>
|
const sortEvents = (events: NostrEvent[]) =>
|
||||||
@@ -52,12 +54,32 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
console.debug('subscription mounted', propsProvider()?.debugId, propsProvider());
|
||||||
|
onCleanup(() => {
|
||||||
|
console.debug('subscription unmount', propsProvider()?.debugId, propsProvider());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const addEvent = (event: NostrEvent) => {
|
||||||
|
const limit = propsProvider()?.limit ?? 50;
|
||||||
|
|
||||||
|
setEvents((current) => {
|
||||||
|
const sorted = sortEvents([event, ...current].slice(0, limit));
|
||||||
|
// FIXME なぜか重複して取得される問題があるが一旦uniqByで対処
|
||||||
|
// https://github.com/syusui-s/rabbit/issues/5
|
||||||
|
const deduped = uniqBy(sorted, (e) => e.id);
|
||||||
|
if (deduped.length !== sorted.length) {
|
||||||
|
console.warn('duplicated event', event);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const startSubscription = () => {
|
const startSubscription = () => {
|
||||||
const props = propsProvider();
|
const props = propsProvider();
|
||||||
if (props == null) return;
|
if (props == null) return;
|
||||||
|
|
||||||
const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props;
|
const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props;
|
||||||
const limit = props.limit ?? 50;
|
|
||||||
|
|
||||||
const sub = pool().sub(relayUrls, filters, options);
|
const sub = pool().sub(relayUrls, filters, options);
|
||||||
let subscribing = true;
|
let subscribing = true;
|
||||||
@@ -74,20 +96,12 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
|
|||||||
if (props.clientEventFilter != null && !props.clientEventFilter(event)) {
|
if (props.clientEventFilter != null && !props.clientEventFilter(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!eose) {
|
if (!eose) {
|
||||||
pushed = true;
|
pushed = true;
|
||||||
storedEvents.push(event);
|
storedEvents.push(event);
|
||||||
} else {
|
} else {
|
||||||
setEvents((current) => {
|
addEvent(event);
|
||||||
const sorted = sortEvents([event, ...current].slice(0, limit));
|
|
||||||
// FIXME なぜか重複して取得される問題があるが一旦uniqByで対処
|
|
||||||
// https://github.com/syusui-s/rabbit/issues/5
|
|
||||||
const deduped = uniqBy(sorted, (e) => e.id);
|
|
||||||
if (deduped.length !== sorted.length) {
|
|
||||||
console.warn('duplicated event', event);
|
|
||||||
}
|
|
||||||
return deduped;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,15 +123,20 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// avoid updating an array too rapidly while this is fetching stored events
|
// avoid updating an array too rapidly while this is fetching stored events
|
||||||
|
let updating = false;
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
|
if (updating) return;
|
||||||
|
updating = true;
|
||||||
if (eose) {
|
if (eose) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
updating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pushed) {
|
if (pushed) {
|
||||||
pushed = false;
|
pushed = false;
|
||||||
setEvents(sortEvents(storedEvents));
|
setEvents(sortEvents(storedEvents));
|
||||||
}
|
}
|
||||||
|
updating = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
import uniqBy from 'lodash/uniqBy';
|
import uniqBy from 'lodash/uniqBy';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { CustomEmojiConfig } from '@/core/useConfig';
|
||||||
|
|
||||||
export const pubkeySchema = z
|
export const pubkeySchema = z
|
||||||
.string()
|
.string()
|
||||||
.length(64)
|
.length(64)
|
||||||
@@ -75,3 +77,6 @@ export const getEmojiPack = async (urlString: string): Promise<AllEmojiPack> =>
|
|||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
return allEmojiPackSchema.parseAsync(await res.json());
|
return allEmojiPackSchema.parseAsync(await res.json());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const convertToEmojiConfig = (emojipack: SimpleEmojiPack): CustomEmojiConfig[] =>
|
||||||
|
Object.entries(emojipack).map(([shortcode, url]) => ({ shortcode, url }));
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const defaultAbsoluteDateLongFormatter = (parsedDate: AbsoluteDate): string => {
|
|||||||
case 'yesterday':
|
case 'yesterday':
|
||||||
case 'abs':
|
case 'abs':
|
||||||
default:
|
default:
|
||||||
return parsedDate.value.toLocaleDateString();
|
return parsedDate.value.toLocaleString();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user