This commit is contained in:
Shusui MOYATANI
2023-05-26 23:14:15 +09:00
parent 7a9632bc48
commit 177b96df32
21 changed files with 366 additions and 90 deletions

View File

@@ -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);
};

View File

@@ -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('');
};

View File

@@ -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>

View File

@@ -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;

View File

@@ -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: [
{

View File

@@ -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>

View 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;

View File

@@ -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: () => 'ミュート',

View File

@@ -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',
},
});

View File

@@ -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':

View File

@@ -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

View 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;

View File

@@ -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;
}

View File

@@ -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);
},

View 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 });
}
};

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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']);
}

View File

@@ -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(() => {

View File

@@ -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 }));

View File

@@ -42,7 +42,7 @@ const defaultAbsoluteDateLongFormatter = (parsedDate: AbsoluteDate): string => {
case 'yesterday':
case 'abs':
default:
return parsedDate.value.toLocaleDateString();
return parsedDate.value.toLocaleString();
}
};