mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
update
This commit is contained in:
@@ -136,20 +136,23 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
||||
|
||||
const uploadFilesMutation = createMutation({
|
||||
mutationKey: ['uploadFiles'],
|
||||
mutationFn: (files: File[]) => {
|
||||
return uploadFiles(uploadNostrBuild)(files)
|
||||
.then((uploadResults) => {
|
||||
uploadResults.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
console.log('succeeded to upload', result);
|
||||
appendText(result.value.imageUrl);
|
||||
resizeTextArea();
|
||||
} else {
|
||||
console.error('failed to upload', result);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
mutationFn: async (files: File[]) => {
|
||||
const uploadResults = await uploadFiles(uploadNostrBuild)(files);
|
||||
const failed: File[] = [];
|
||||
|
||||
uploadResults.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
appendText(result.value.imageUrl);
|
||||
resizeTextArea();
|
||||
} else {
|
||||
failed.push(files[i]);
|
||||
}
|
||||
});
|
||||
|
||||
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(),
|
||||
...pubkeyReferences, // 本文中の公開鍵(npub)
|
||||
]),
|
||||
rootEventId: replyTo()?.rootEvent()?.id ?? replyTo()?.id,
|
||||
rootEventId: replyTo()?.rootEvent()?.id,
|
||||
replyEventId: replyTo()?.id,
|
||||
};
|
||||
}
|
||||
@@ -232,22 +235,6 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
||||
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) => {
|
||||
setText(ev.currentTarget.value);
|
||||
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) => {
|
||||
ev.preventDefault();
|
||||
if (uploadFilesMutation.isLoading) return;
|
||||
if (!ensureUploaderAgreement()) return;
|
||||
// if (!ensureUploaderAgreement()) return;
|
||||
|
||||
const files = [...(ev.currentTarget.files ?? [])];
|
||||
uploadFilesMutation.mutate(files);
|
||||
@@ -281,7 +287,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
||||
const handleDrop: JSX.EventHandler<HTMLTextAreaElement, DragEvent> = (ev) => {
|
||||
ev.preventDefault();
|
||||
if (uploadFilesMutation.isLoading) return;
|
||||
if (!ensureUploaderAgreement()) return;
|
||||
// if (!ensureUploaderAgreement()) return;
|
||||
const files = [...(ev?.dataTransfer?.files ?? [])];
|
||||
uploadFilesMutation.mutate(files);
|
||||
};
|
||||
@@ -301,7 +307,7 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
||||
}
|
||||
});
|
||||
if (files.length === 0) return;
|
||||
if (!ensureUploaderAgreement()) return;
|
||||
// if (!ensureUploaderAgreement()) return;
|
||||
|
||||
uploadFilesMutation.mutate(files);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ const SearchButton = () => {
|
||||
ev.preventDefault();
|
||||
|
||||
saveColumn(createSearchColumn({ query: query() }));
|
||||
request({ command: 'moveToLastColumn' }).catch((err) => console.log(err));
|
||||
request({ command: 'moveToLastColumn' }).catch((err) => console.error(err));
|
||||
popupRef?.close();
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
@@ -57,7 +57,9 @@ const Column: Component<ColumnProps> = (props) => {
|
||||
fallback={
|
||||
<>
|
||||
<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>
|
||||
</button>
|
||||
</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} />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const Columns = () => {
|
||||
const { config } = useConfig();
|
||||
|
||||
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}>
|
||||
{(column, index) => {
|
||||
const columnIndex = () => index() + 1;
|
||||
|
||||
@@ -29,6 +29,7 @@ const FollowingColumn: Component<FollowingColumnDisplayProps> = (props) => {
|
||||
const authors = uniq([...followingPubkeys()]);
|
||||
if (authors.length === 0) return null;
|
||||
return {
|
||||
debugId: 'following',
|
||||
relayUrls: config().relayUrls,
|
||||
filters: [
|
||||
{
|
||||
|
||||
@@ -2,38 +2,15 @@ import { Component, Show } from 'solid-js';
|
||||
|
||||
import ChatBubbleLeftRight from 'heroicons/24/outline/chat-bubble-left-right.svg';
|
||||
import { Event as NostrEvent } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import EventLink from '@/components/EventLink';
|
||||
import { isImageUrl } from '@/utils/imageUrl';
|
||||
import { parseChannelMeta } from '@/nostr/event/channel';
|
||||
|
||||
export type ChannelInfoProps = {
|
||||
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 parsedContent = () => parseContent(props.event.content);
|
||||
const parsedContent = () => parseChannelMeta(props.event.content);
|
||||
|
||||
return (
|
||||
<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 useModalState from '@/hooks/useModalState';
|
||||
import usePubkey from '@/nostr/usePubkey';
|
||||
import { simpleEmojiPackSchema, convertToEmojiConfig } from '@/utils/emojipack';
|
||||
import ensureNonNull from '@/utils/ensureNonNull';
|
||||
|
||||
type ConfigProps = {
|
||||
@@ -228,6 +229,8 @@ const EmojiConfig = () => {
|
||||
ev.preventDefault();
|
||||
if (shortcodeInput().length === 0 || urlInput().length === 0) return;
|
||||
saveEmoji({ shortcode: shortcodeInput(), url: urlInput() });
|
||||
setShortcodeInput('');
|
||||
setUrlInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -253,6 +256,7 @@ const EmojiConfig = () => {
|
||||
class="flex-1 rounded-md focus:border-rose-100 focus:ring-rose-300"
|
||||
type="text"
|
||||
name="shortcode"
|
||||
placeholder="smiley"
|
||||
value={shortcodeInput()}
|
||||
pattern="^\\w+$"
|
||||
required
|
||||
@@ -266,7 +270,7 @@ const EmojiConfig = () => {
|
||||
type="text"
|
||||
name="url"
|
||||
value={urlInput()}
|
||||
placeholder="https://.../emoji.png"
|
||||
placeholder="https://example.com/smiley.png"
|
||||
pattern={HttpUrlRegex}
|
||||
required
|
||||
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 { config, removeMutedPubkey, addMutedKeyword, removeMutedKeyword } = useConfig();
|
||||
|
||||
@@ -428,7 +472,12 @@ const ConfigUI = (props: ConfigProps) => {
|
||||
{
|
||||
name: () => 'カスタム絵文字',
|
||||
icon: () => <FaceSmile />,
|
||||
render: () => <EmojiConfig />,
|
||||
render: () => (
|
||||
<>
|
||||
<EmojiConfig />
|
||||
<EmojiImport />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: () => 'ミュート',
|
||||
|
||||
@@ -140,7 +140,7 @@ export const createJapanRelaysColumn = () =>
|
||||
relayUrls: relaysOnlyAvailableInJP,
|
||||
contentFilter: {
|
||||
filterType: 'Regex',
|
||||
regex: '[\\p{scx=Hiragana}\\p{scx=Katakana}]',
|
||||
regex: '[\\p{sc=Hiragana}\\p{sc=Katakana}ー]',
|
||||
flag: 'u',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
*/
|
||||
export type ContentFilterAnd = { filterType: 'AND'; 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 ContentFilterRegex = { filterType: 'Regex'; regex: string; flag: string };
|
||||
|
||||
export type ContentFilter =
|
||||
| ContentFilterAnd
|
||||
| ContentFilterOr
|
||||
| ContentFilterNot
|
||||
| ContentFilterTextInclude
|
||||
| ContentFilterRegex;
|
||||
|
||||
@@ -38,6 +40,8 @@ export const applyContentFilter =
|
||||
return filter.children.every((child) => applyContentFilter(child)(content));
|
||||
case 'OR':
|
||||
return filter.children.some((child) => applyContentFilter(child)(content));
|
||||
case 'NOT':
|
||||
return !applyContentFilter(filter.child)(content);
|
||||
case 'Text':
|
||||
return content.includes(filter.text);
|
||||
case 'Regex':
|
||||
|
||||
@@ -51,6 +51,7 @@ type UseConfig = {
|
||||
initializeColumns: (param: { pubkey: string }) => void;
|
||||
// emoji
|
||||
saveEmoji: (emoji: CustomEmojiConfig) => void;
|
||||
saveEmojis: (emojis: CustomEmojiConfig[]) => void;
|
||||
removeEmoji: (shortcode: string) => void;
|
||||
getEmoji: (shortcode: string) => CustomEmojiConfig | undefined;
|
||||
// mute
|
||||
@@ -156,6 +157,13 @@ const useConfig = (): UseConfig => {
|
||||
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) => {
|
||||
setConfig('customEmojis', (current) => ({ ...current, [shortcode]: undefined }));
|
||||
};
|
||||
@@ -177,7 +185,7 @@ const useConfig = (): UseConfig => {
|
||||
return (
|
||||
isPubkeyMuted(event.pubkey) ||
|
||||
ev.mentionedPubkeys().some(isPubkeyMuted) ||
|
||||
hasMutedKeyword(event)
|
||||
(event.kind === Kind.Text && hasMutedKeyword(event))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -212,6 +220,7 @@ const useConfig = (): UseConfig => {
|
||||
initializeColumns,
|
||||
// emoji
|
||||
saveEmoji,
|
||||
saveEmojis,
|
||||
removeEmoji,
|
||||
getEmoji,
|
||||
// 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 {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.link {
|
||||
@@ -40,3 +38,20 @@ em-emoji-picker {
|
||||
width: 360px;
|
||||
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[] {
|
||||
return event.tags.filter(ensureSchema(EmojiTagSchema));
|
||||
},
|
||||
findTagsByName(name: string): string[][] {
|
||||
return event.tags.filter(([tagName]) => tagName === name);
|
||||
},
|
||||
taggedEventIds(): string[] {
|
||||
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(
|
||||
genQueryKey,
|
||||
({ queryKey, signal }) => {
|
||||
console.debug('useFollowings');
|
||||
const [, currentProps] = queryKey;
|
||||
if (currentProps == null) return Promise.resolve(null);
|
||||
const { pubkey } = currentProps;
|
||||
|
||||
@@ -5,10 +5,54 @@ import { describe, it } from 'vitest';
|
||||
import { buildTags } from '@/nostr/useCommands';
|
||||
|
||||
describe('buildTags', () => {
|
||||
it('should place a reply tag as first one if it is an only element', () => {
|
||||
const replyEventId = '6b280916873768d752cb95a0d2787a184926db8b717394c66ae255b221e607a8a';
|
||||
it('should return a root tag if only rootEventId is given', () => {
|
||||
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 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);
|
||||
});
|
||||
|
||||
@@ -55,10 +55,14 @@ export const buildTags = ({
|
||||
if (rootEventId != null) {
|
||||
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) {
|
||||
mentionEventIds.forEach((id) => eTags.push(['e', id, '', 'mention']));
|
||||
}
|
||||
if (replyEventId != null) {
|
||||
if (rootEventId != null && replyEventId != null && rootEventId !== replyEventId) {
|
||||
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 useConfig from '@/core/useConfig';
|
||||
import usePool from '@/nostr/usePool';
|
||||
import useStats from '@/nostr/useStats';
|
||||
import epoch from '@/utils/epoch';
|
||||
|
||||
import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
|
||||
|
||||
@@ -25,6 +26,7 @@ export type UseSubscriptionProps = {
|
||||
onEvent?: (event: NostrEvent & { id: string }) => void;
|
||||
onEOSE?: () => void;
|
||||
signal?: AbortSignal;
|
||||
debugId?: string;
|
||||
};
|
||||
|
||||
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 props = propsProvider();
|
||||
if (props == null) return;
|
||||
|
||||
const { relayUrls, filters, options, onEvent, onEOSE, continuous = true } = props;
|
||||
const limit = props.limit ?? 50;
|
||||
|
||||
const sub = pool().sub(relayUrls, filters, options);
|
||||
let subscribing = true;
|
||||
@@ -74,20 +96,12 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
|
||||
if (props.clientEventFilter != null && !props.clientEventFilter(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eose) {
|
||||
pushed = true;
|
||||
storedEvents.push(event);
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
addEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,15 +123,20 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
|
||||
});
|
||||
|
||||
// avoid updating an array too rapidly while this is fetching stored events
|
||||
let updating = false;
|
||||
const intervalId = setInterval(() => {
|
||||
if (updating) return;
|
||||
updating = true;
|
||||
if (eose) {
|
||||
clearInterval(intervalId);
|
||||
updating = false;
|
||||
return;
|
||||
}
|
||||
if (pushed) {
|
||||
pushed = false;
|
||||
setEvents(sortEvents(storedEvents));
|
||||
}
|
||||
updating = false;
|
||||
}, 100);
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomEmojiConfig } from '@/core/useConfig';
|
||||
|
||||
export const pubkeySchema = z
|
||||
.string()
|
||||
.length(64)
|
||||
@@ -75,3 +77,6 @@ export const getEmojiPack = async (urlString: string): Promise<AllEmojiPack> =>
|
||||
const res = await fetch(url);
|
||||
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 'abs':
|
||||
default:
|
||||
return parsedDate.value.toLocaleDateString();
|
||||
return parsedDate.value.toLocaleString();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user