feat: note publishing progress
This commit is contained in:
parent
c239fba3df
commit
0e4a040750
@ -347,6 +347,16 @@
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0453 1.07385C11.0306 0.603671 8.91602 0.82887 7.04549 1.71284C5.17495 2.5968 3.65845 4.08754 2.74257 5.94266C1.82669 7.79778 1.5653 9.90817 2.00091 11.9307C2.43651 13.9532 3.54348 15.7689 5.14183 17.0825C6.74018 18.3961 8.7359 19.1304 10.8045 19.166C12.8731 19.2015 14.8929 18.5363 16.5354 17.2784C16.9008 16.9986 16.9702 16.4755 16.6904 16.1101C16.4105 15.7447 15.8875 15.6754 15.5221 15.9552C14.1782 16.9844 12.5256 17.5287 10.8331 17.4995C9.14066 17.4704 7.5078 16.8697 6.20006 15.7949C4.89232 14.7201 3.98661 13.2346 3.63021 11.5798C3.27381 9.92499 3.48767 8.1983 4.23703 6.68048C4.98638 5.16265 6.22716 3.94296 7.7576 3.21971C9.28804 2.49647 11.0181 2.31221 12.6666 2.69691C14.315 3.08161 15.7848 4.01263 16.837 5.33858C17.8892 6.66453 18.462 8.30748 18.4621 10.0002V10.8335C18.4621 11.2755 18.2865 11.6994 17.9739 12.012C17.6614 12.3245 17.2374 12.5001 16.7954 12.5001C16.3534 12.5001 15.9295 12.3245 15.6169 12.012C15.3043 11.6994 15.1288 11.2755 15.1288 10.8335V6.6668C15.1288 6.20656 14.7557 5.83347 14.2954 5.83347C13.8353 5.83347 13.4622 6.2064 13.4621 6.66651C12.7657 6.14343 11.9001 5.83348 10.9621 5.83348C8.6609 5.83348 6.79542 7.69896 6.79542 10.0001C6.79542 12.3013 8.6609 14.1668 10.9621 14.1668C12.2022 14.1668 13.3157 13.6251 14.079 12.7654C14.186 12.9159 14.3061 13.0582 14.4384 13.1905C15.0635 13.8156 15.9114 14.1668 16.7954 14.1668C17.6795 14.1668 18.5273 13.8156 19.1524 13.1905C19.7776 12.5654 20.1288 11.7175 20.1288 10.8335V10.0001C20.1286 7.93119 19.4286 5.92319 18.1426 4.30257C16.8565 2.68195 15.0601 1.54404 13.0453 1.07385ZM13.4621 9.99665V10.0036C13.4602 11.3827 12.3416 12.5001 10.9621 12.5001C9.58138 12.5001 8.46209 11.3809 8.46209 10.0001C8.46209 8.61943 9.58138 7.50014 10.9621 7.50014C12.3416 7.50014 13.4602 8.61754 13.4621 9.99665Z" fill="currentColor"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="trash-01" viewBox="0 0 20 21" fill="none">
|
||||
<g>
|
||||
<path d="M13.3333 5.5013V4.83464C13.3333 3.90121 13.3333 3.4345 13.1517 3.07798C12.9919 2.76438 12.7369 2.50941 12.4233 2.34962C12.0668 2.16797 11.6001 2.16797 10.6667 2.16797H9.33333C8.39991 2.16797 7.9332 2.16797 7.57668 2.34962C7.26308 2.50941 7.00811 2.76438 6.84832 3.07798C6.66667 3.4345 6.66667 3.90121 6.66667 4.83464V5.5013M8.33333 10.0846V14.2513M11.6667 10.0846V14.2513M2.5 5.5013H17.5M15.8333 5.5013V14.8346C15.8333 16.2348 15.8333 16.9348 15.5608 17.4696C15.3212 17.94 14.9387 18.3225 14.4683 18.5622C13.9335 18.8346 13.2335 18.8346 11.8333 18.8346H8.16667C6.76654 18.8346 6.06647 18.8346 5.53169 18.5622C5.06129 18.3225 4.67883 17.94 4.43915 17.4696C4.16667 16.9348 4.16667 16.2348 4.16667 14.8346V5.5013" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<svg id="refresh-ccw-01" viewBox="0 0 20 21" fill="none">
|
||||
<g>
|
||||
<path d="M5.28415 5.78889C6.49163 4.58059 8.15782 3.83464 9.99983 3.83464C13.6817 3.83464 16.6665 6.8194 16.6665 10.5013C16.6665 14.1832 13.6817 17.168 9.99983 17.168C6.96172 17.168 4.39626 15.135 3.59364 12.3536C3.46603 11.9114 3.00412 11.6564 2.56193 11.784C2.11973 11.9116 1.86471 12.3735 1.99231 12.8157C2.99526 16.2914 6.19946 18.8346 9.99983 18.8346C14.6022 18.8346 18.3332 15.1037 18.3332 10.5013C18.3332 5.89893 14.6022 2.16797 9.99983 2.16797C7.69784 2.16797 5.61252 3.10246 4.10524 4.61079C3.57518 5.14121 3.00475 5.7994 2.49992 6.41325V3.83464C2.49992 3.3744 2.12682 3.0013 1.66659 3.0013C1.20635 3.0013 0.833252 3.3744 0.833252 3.83464V8.83464C0.833252 9.29487 1.20635 9.66797 1.66659 9.66797H6.66659C7.12682 9.66797 7.49992 9.29487 7.49992 8.83464C7.49992 8.3744 7.12682 8.0013 6.66659 8.0013H3.35886C3.92894 7.28583 4.64729 6.4262 5.28415 5.78889Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 95 KiB |
@ -1,14 +1,24 @@
|
||||
button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spinner-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.spinner-button > span {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.light .spinner-button {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--font-secondary);
|
||||
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 1px;
|
||||
}
|
||||
|
||||
.light .spinner-button:hover {
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0 1px 3px;
|
||||
}
|
||||
|
@ -30,10 +30,10 @@ const AsyncButton = React.forwardRef<HTMLButtonElement, AsyncButtonProps>((props
|
||||
return (
|
||||
<button
|
||||
ref={ref as ForwardedRef<HTMLButtonElement>}
|
||||
className="spinner-button"
|
||||
type="button"
|
||||
disabled={loading || props.disabled}
|
||||
{...props}
|
||||
className={`spinner-button${props.className ? ` ${props.className}` : ""}`}
|
||||
onClick={handle}>
|
||||
<span style={{ visibility: loading ? "hidden" : "visible" }}>{props.children}</span>
|
||||
{loading && (
|
||||
|
85
packages/app/src/Element/Event/NoteBroadcaster.tsx
Normal file
85
packages/app/src/Element/Event/NoteBroadcaster.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { removeUndefined } from "@snort/shared";
|
||||
import { NostrEvent, OkResponse } from "@snort/system";
|
||||
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import Icon from "Icons/Icon";
|
||||
import { getRelayName } from "SnortUtils";
|
||||
import { System } from "index";
|
||||
|
||||
export function NoteBroadcaster({
|
||||
evs,
|
||||
onClose,
|
||||
customRelays,
|
||||
}: {
|
||||
evs: Array<NostrEvent>;
|
||||
onClose: () => void;
|
||||
customRelays?: Array<string>;
|
||||
}) {
|
||||
const [results, setResults] = useState<Array<OkResponse>>([]);
|
||||
|
||||
async function sendEventToRelays(ev: NostrEvent) {
|
||||
if (customRelays) {
|
||||
return removeUndefined(
|
||||
await Promise.all(
|
||||
customRelays.map(async r => {
|
||||
try {
|
||||
return await System.WriteOnceToRelay(r, ev);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return await System.BroadcastEvent(ev, r => setResults(x => [...x, r]));
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNote() {
|
||||
const results = await Promise.all(evs.map(a => sendEventToRelays(a)).flat());
|
||||
if (results.flat().every(a => a.ok)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
sendNote().catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-column g4">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="Sending notes and other stuff" />
|
||||
</h3>
|
||||
{results
|
||||
.filter(a => a.message !== "Duplicate request")
|
||||
.sort(a => (a.ok ? -1 : 1))
|
||||
.map(r => (
|
||||
<div className="p-compact flex-row g16">
|
||||
<Icon name={r.ok ? "check" : "x-close"} className={r.ok ? "success" : "error"} />
|
||||
<div className="flex-column f-grow g4">
|
||||
<b>{getRelayName(r.relay)}</b>
|
||||
{r.message && <small>{r.message}</small>}
|
||||
</div>
|
||||
{!r.ok && false && (
|
||||
<div className="flex g8">
|
||||
<AsyncButton onClick={() => {}} className="p4 br-compact flex f-center secondary">
|
||||
<Icon name="refresh-ccw-01" />
|
||||
</AsyncButton>
|
||||
<AsyncButton onClick={() => {}} className="p4 br-compact flex f-center secondary">
|
||||
<Icon name="trash-01" className="trash-icon" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-row g8">
|
||||
<button type="button" onClick={() => onClose()}>
|
||||
<FormattedMessage defaultMessage="Close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,14 +1,6 @@
|
||||
import "./NoteCreator.css";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import {
|
||||
EventKind,
|
||||
NostrPrefix,
|
||||
TaggedNostrEvent,
|
||||
EventBuilder,
|
||||
tryParseNostrLink,
|
||||
NostrLink,
|
||||
NostrEvent,
|
||||
} from "@snort/system";
|
||||
import { EventKind, NostrPrefix, TaggedNostrEvent, EventBuilder, tryParseNostrLink, NostrLink } from "@snort/system";
|
||||
|
||||
import Icon from "Icons/Icon";
|
||||
import useEventPublisher from "Hooks/useEventPublisher";
|
||||
@ -21,12 +13,13 @@ import Note from "Element/Event/Note";
|
||||
|
||||
import { ClipboardEventHandler } from "react";
|
||||
import useLogin from "Hooks/useLogin";
|
||||
import { System, WasmPowWorker } from "index";
|
||||
import { WasmPowWorker } from "index";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import { AsyncIcon } from "Element/AsyncIcon";
|
||||
import { fetchNip05Pubkey } from "@snort/shared";
|
||||
import { ZapTarget } from "Zapper";
|
||||
import { useNoteCreator } from "State/NoteCreator";
|
||||
import { NoteBroadcaster } from "./NoteBroadcaster";
|
||||
|
||||
export function NoteCreator() {
|
||||
const { formatMessage } = useIntl();
|
||||
@ -123,24 +116,11 @@ export function NoteCreator() {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEventToRelays(ev: NostrEvent) {
|
||||
if (note.selectedCustomRelays) {
|
||||
await Promise.all(note.selectedCustomRelays.map(r => System.WriteOnceToRelay(r, ev)));
|
||||
} else {
|
||||
System.BroadcastEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNote() {
|
||||
const ev = await buildNote();
|
||||
if (ev) {
|
||||
await sendEventToRelays(ev);
|
||||
for (const oe of note.otherEvents ?? []) {
|
||||
await sendEventToRelays(oe);
|
||||
}
|
||||
note.update(v => {
|
||||
v.reset();
|
||||
v.show = false;
|
||||
note.update(n => {
|
||||
n.sending = (note.otherEvents ?? []).concat(ev);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -327,90 +307,8 @@ export function NoteCreator() {
|
||||
));
|
||||
}*/
|
||||
|
||||
const handlePaste: ClipboardEventHandler<HTMLDivElement> = evt => {
|
||||
if (evt.clipboardData) {
|
||||
const clipboardItems = evt.clipboardData.items;
|
||||
const items: DataTransferItem[] = Array.from(clipboardItems).filter(function (item: DataTransferItem) {
|
||||
// Filter the image items only
|
||||
return /^image\//.test(item.type);
|
||||
});
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = items[0];
|
||||
const blob = item.getAsFile();
|
||||
if (blob) {
|
||||
uploadFile(blob);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!note.show) return null;
|
||||
function noteCreatorAdvanced() {
|
||||
return (
|
||||
<Modal id="note-creator" className="note-creator-modal" onClose={() => note.update(v => (v.show = false))}>
|
||||
{note.replyTo && (
|
||||
<Note
|
||||
data={note.replyTo}
|
||||
related={[]}
|
||||
options={{
|
||||
showFooter: false,
|
||||
showContextMenu: false,
|
||||
showTime: false,
|
||||
canClick: false,
|
||||
showMedia: false,
|
||||
longFormPreview: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{note.preview && getPreviewNote()}
|
||||
{!note.preview && (
|
||||
<div onPaste={handlePaste} className={`note-creator${note.pollOptions ? " poll" : ""}`}>
|
||||
<Textarea
|
||||
autoFocus
|
||||
className={`textarea ${note.active ? "textarea--focused" : ""}`}
|
||||
onChange={c => onChange(c)}
|
||||
value={note.note}
|
||||
onFocus={() => note.update(v => (v.active = true))}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" && e.metaKey) {
|
||||
sendNote().catch(console.warn);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{renderPollOptions()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex f-space">
|
||||
<div className="flex g8">
|
||||
<ProfileImage
|
||||
pubkey={login.publicKey ?? ""}
|
||||
className="note-creator-icon"
|
||||
link=""
|
||||
showUsername={false}
|
||||
showFollowingMark={false}
|
||||
/>
|
||||
{note.pollOptions === undefined && !note.replyTo && (
|
||||
<div className="note-creator-icon">
|
||||
<Icon name="pie-chart" onClick={() => note.update(v => (v.pollOptions = ["A", "B"]))} size={24} />
|
||||
</div>
|
||||
)}
|
||||
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
|
||||
<button className="secondary" onClick={() => note.update(v => (v.advanced = !v.advanced))}>
|
||||
<FormattedMessage defaultMessage="Advanced" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex g8">
|
||||
<button className="secondary" onClick={cancel}>
|
||||
<FormattedMessage defaultMessage="Cancel" />
|
||||
</button>
|
||||
<AsyncButton onClick={onSubmit}>
|
||||
{note.replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
{note.error && <span className="error">{note.error}</span>}
|
||||
{note.advanced && (
|
||||
<>
|
||||
<button className="secondary" onClick={loadPreview}>
|
||||
<FormattedMessage defaultMessage="Toggle Preview" />
|
||||
@ -507,7 +405,119 @@ export function NoteCreator() {
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function noteCreatorFooter() {
|
||||
return (
|
||||
<div className="flex f-space">
|
||||
<div className="flex g8">
|
||||
<ProfileImage
|
||||
pubkey={login.publicKey ?? ""}
|
||||
className="note-creator-icon"
|
||||
link=""
|
||||
showUsername={false}
|
||||
showFollowingMark={false}
|
||||
/>
|
||||
{note.pollOptions === undefined && !note.replyTo && (
|
||||
<div className="note-creator-icon">
|
||||
<Icon name="pie-chart" onClick={() => note.update(v => (v.pollOptions = ["A", "B"]))} size={24} />
|
||||
</div>
|
||||
)}
|
||||
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
|
||||
<button className="secondary" onClick={() => note.update(v => (v.advanced = !v.advanced))}>
|
||||
<FormattedMessage defaultMessage="Advanced" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex g8">
|
||||
<button className="secondary" onClick={cancel}>
|
||||
<FormattedMessage defaultMessage="Cancel" />
|
||||
</button>
|
||||
<AsyncButton onClick={onSubmit}>
|
||||
{note.replyTo ? <FormattedMessage defaultMessage="Reply" /> : <FormattedMessage defaultMessage="Send" />}
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePaste: ClipboardEventHandler<HTMLDivElement> = evt => {
|
||||
if (evt.clipboardData) {
|
||||
const clipboardItems = evt.clipboardData.items;
|
||||
const items: DataTransferItem[] = Array.from(clipboardItems).filter(function (item: DataTransferItem) {
|
||||
// Filter the image items only
|
||||
return /^image\//.test(item.type);
|
||||
});
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = items[0];
|
||||
const blob = item.getAsFile();
|
||||
if (blob) {
|
||||
uploadFile(blob);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function noteCreatorForm() {
|
||||
return (
|
||||
<>
|
||||
{note.replyTo && (
|
||||
<Note
|
||||
data={note.replyTo}
|
||||
related={[]}
|
||||
options={{
|
||||
showFooter: false,
|
||||
showContextMenu: false,
|
||||
showTime: false,
|
||||
canClick: false,
|
||||
showMedia: false,
|
||||
longFormPreview: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{note.preview && getPreviewNote()}
|
||||
{!note.preview && (
|
||||
<div onPaste={handlePaste} className={`note-creator${note.pollOptions ? " poll" : ""}`}>
|
||||
<Textarea
|
||||
autoFocus
|
||||
className={`textarea ${note.active ? "textarea--focused" : ""}`}
|
||||
onChange={c => onChange(c)}
|
||||
value={note.note}
|
||||
onFocus={() => note.update(v => (v.active = true))}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" && e.metaKey) {
|
||||
sendNote().catch(console.warn);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{renderPollOptions()}
|
||||
</div>
|
||||
)}
|
||||
{noteCreatorFooter()}
|
||||
{note.error && <span className="error">{note.error}</span>}
|
||||
{note.advanced && noteCreatorAdvanced()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!note.show) return null;
|
||||
return (
|
||||
<Modal id="note-creator" className="note-creator-modal" onClose={() => note.update(v => (v.show = false))}>
|
||||
{note.sending && (
|
||||
<NoteBroadcaster
|
||||
evs={note.sending}
|
||||
onClose={() => {
|
||||
note.update(n => {
|
||||
n.reset();
|
||||
n.show = false;
|
||||
});
|
||||
}}
|
||||
customRelays={note.selectedCustomRelays}
|
||||
/>
|
||||
)}
|
||||
{!note.sending && noteCreatorForm()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { TaggedNostrEvent, EventKind, MetadataCache } from "@snort/system";
|
||||
import { getDisplayName } from "Element/User/DisplayName";
|
||||
import { MentionRegex } from "Const";
|
||||
import { defaultAvatar, tagFilterOfTextRepost, unwrap } from "SnortUtils";
|
||||
import { defaultAvatar, tagFilterOfTextRepost } from "SnortUtils";
|
||||
import { UserCache } from "Cache";
|
||||
import { LoginSession } from "Login";
|
||||
import { removeUndefined } from "@snort/shared";
|
||||
|
||||
export interface NotificationRequest {
|
||||
title: string;
|
||||
@ -20,10 +21,7 @@ export async function makeNotification(ev: TaggedNostrEvent): Promise<Notificati
|
||||
}
|
||||
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1])]);
|
||||
await UserCache.buffer([...pubkeys]);
|
||||
const allUsers = [...pubkeys]
|
||||
.map(a => UserCache.getFromCache(a))
|
||||
.filter(a => a)
|
||||
.map(a => unwrap(a));
|
||||
const allUsers = removeUndefined([...pubkeys].map(a => UserCache.getFromCache(a)));
|
||||
const fromUser = UserCache.getFromCache(ev.pubkey);
|
||||
const name = getDisplayName(fromUser, ev.pubkey);
|
||||
const avatarUrl = fromUser?.picture || defaultAvatar(ev.pubkey);
|
||||
|
@ -131,7 +131,7 @@ export default function ProfilePage({ id: propId }: ProfilePageProps) {
|
||||
return (
|
||||
<div className="flex g8">
|
||||
{cover && <ProxyImg src={cover} size={40} />}
|
||||
<small>🎵 {unwrap(status.music).content}</small>
|
||||
🎵 {unwrap(status.music).content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,7 +1,3 @@
|
||||
.preferences small {
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.preferences select {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
@ -57,8 +57,3 @@
|
||||
.settings .actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings small {
|
||||
font-size: 14px;
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
@ -17,6 +17,8 @@ interface NoteCreatorDataSnapshot {
|
||||
sensitive?: string;
|
||||
pollOptions?: Array<string>;
|
||||
otherEvents?: Array<NostrEvent>;
|
||||
sending?: Array<NostrEvent>;
|
||||
sendStarted: boolean;
|
||||
reset: () => void;
|
||||
update: (fn: (v: NoteCreatorDataSnapshot) => void) => void;
|
||||
}
|
||||
@ -32,6 +34,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
|
||||
error: "",
|
||||
active: false,
|
||||
advanced: false,
|
||||
sendStarted: false,
|
||||
reset: () => {
|
||||
this.#reset(this.#data);
|
||||
this.notifyChange(this.#data);
|
||||
@ -49,6 +52,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
|
||||
d.error = "";
|
||||
d.active = false;
|
||||
d.advanced = false;
|
||||
d.sendStarted = false;
|
||||
d.preview = undefined;
|
||||
d.replyTo = undefined;
|
||||
d.selectedCustomRelays = undefined;
|
||||
@ -56,6 +60,7 @@ class NoteCreatorStore extends ExternalStore<NoteCreatorDataSnapshot> {
|
||||
d.sensitive = undefined;
|
||||
d.pollOptions = undefined;
|
||||
d.otherEvents = undefined;
|
||||
d.sending = undefined;
|
||||
}
|
||||
|
||||
takeSnapshot(): NoteCreatorDataSnapshot {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { ExternalStore, FeedCache, dedupe } from "@snort/shared";
|
||||
import { ExternalStore, FeedCache, dedupe, removeUndefined } from "@snort/shared";
|
||||
import { RequestBuilder, NostrEvent, EventKind, SystemInterface, TaggedNostrEvent } from "@snort/system";
|
||||
import { LoginSession } from "Login";
|
||||
import { unwrap } from "SnortUtils";
|
||||
import { Chat, ChatSystem, ChatType, lastReadInChat } from "chat";
|
||||
|
||||
export class Nip29ChatSystem extends ExternalStore<Array<Chat>> implements ChatSystem {
|
||||
@ -45,11 +44,7 @@ export class Nip29ChatSystem extends ExternalStore<Array<Chat>> implements ChatS
|
||||
listChats(): Chat[] {
|
||||
const allMessages = this.#nip29Chats();
|
||||
const groups = dedupe(
|
||||
allMessages
|
||||
.map(a => a.tags.find(b => b[0] === "g"))
|
||||
.filter(a => a !== undefined)
|
||||
.map(a => unwrap(a))
|
||||
.map(a => `${a[2]}${a[1]}`),
|
||||
removeUndefined(allMessages.map(a => a.tags.find(b => b[0] === "g"))).map(a => `${a[2]}${a[1]}`),
|
||||
);
|
||||
return groups.map(g => {
|
||||
const [relay, channel] = g.split("/", 2);
|
||||
|
@ -167,10 +167,18 @@ a.ext {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.br-compact {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.p {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.p-compact {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.p4 {
|
||||
padding: 4px;
|
||||
}
|
||||
@ -212,7 +220,14 @@ a.ext {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--font-secondary-color);
|
||||
font-size: 14px;
|
||||
line-height: 22px; /* 157.143% */
|
||||
}
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
@ -408,6 +423,11 @@ input:disabled {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -646,6 +666,18 @@ div.form-col {
|
||||
background-color: var(--repost);
|
||||
}
|
||||
|
||||
.bg-dark {
|
||||
background-color: var(--gray-dark);
|
||||
}
|
||||
|
||||
.bg-superdark {
|
||||
background-color: var(--gray-superdark);
|
||||
}
|
||||
|
||||
.bg-ultradark {
|
||||
background-color: var(--gray-ultradark);
|
||||
}
|
||||
|
||||
.text-zap {
|
||||
color: var(--zap);
|
||||
}
|
||||
@ -865,17 +897,6 @@ svg.zap-solid {
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.light .spinner-button {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--font-secondary);
|
||||
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 1px;
|
||||
}
|
||||
|
||||
.light .spinner-button:hover {
|
||||
box-shadow: rgba(0, 0, 0, 0.2) 0 1px 3px;
|
||||
}
|
||||
|
||||
.main-content.p {
|
||||
border-bottom: 0;
|
||||
border-top: 0;
|
||||
|
@ -47,6 +47,7 @@ import { preload, RelayMetrics, SystemDb, UserCache, UserRelays } from "Cache";
|
||||
import { LoginStore } from "Login";
|
||||
import { SnortDeckLayout } from "Pages/DeckLayout";
|
||||
import FreeNostrAddressPage from "./Pages/FreeNostrAddressPage";
|
||||
import { removeUndefined } from "@snort/shared";
|
||||
|
||||
const WasmQueryOptimizer = {
|
||||
expandFilter: (f: ReqFilter) => {
|
||||
@ -107,7 +108,7 @@ async function fetchProfile(key: string) {
|
||||
*/
|
||||
if (CONFIG.httpCache) {
|
||||
System.ProfileLoader.loaderFn = async (keys: Array<string>) => {
|
||||
return (await Promise.all(keys.map(a => fetchProfile(a)))).filter(a => a !== undefined).map(a => unwrap(a));
|
||||
return removeUndefined(await Promise.all(keys.map(a => fetchProfile(a))));
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1335,6 +1335,9 @@
|
||||
"rT14Ow": {
|
||||
"defaultMessage": "Add Relays"
|
||||
},
|
||||
"rbrahO": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"reJ6SM": {
|
||||
"defaultMessage": "It is recommended to use one of the following browser extensions if you are on a desktop computer to secure your key:"
|
||||
},
|
||||
@ -1389,6 +1392,9 @@
|
||||
"uc0din": {
|
||||
"defaultMessage": "Send sats splits to"
|
||||
},
|
||||
"ugyJnE": {
|
||||
"defaultMessage": "Sending notes and other stuff"
|
||||
},
|
||||
"usAvMr": {
|
||||
"defaultMessage": "Edit Profile"
|
||||
},
|
||||
|
@ -437,6 +437,7 @@
|
||||
"r3C4x/": "Software",
|
||||
"r5srDR": "Enter wallet password",
|
||||
"rT14Ow": "Add Relays",
|
||||
"rbrahO": "Close",
|
||||
"reJ6SM": "It is recommended to use one of the following browser extensions if you are on a desktop computer to secure your key:",
|
||||
"rfuMjE": "(Default)",
|
||||
"rmdsT4": "{n} days",
|
||||
@ -455,6 +456,7 @@
|
||||
"u4bHcR": "Check out the code here: {link}",
|
||||
"uSV4Ti": "Reposts need to be manually confirmed",
|
||||
"uc0din": "Send sats splits to",
|
||||
"ugyJnE": "Sending notes and other stuff",
|
||||
"usAvMr": "Edit Profile",
|
||||
"ut+2Cd": "Get a partner identifier",
|
||||
"v8lolG": "Start chat",
|
||||
|
@ -1,5 +1,5 @@
|
||||
import debug from "debug";
|
||||
import { unixNowMs, unwrap } from "./utils";
|
||||
import { removeUndefined, unixNowMs, unwrap } from "./utils";
|
||||
import { DexieTableLike } from "./dexie-like";
|
||||
|
||||
type HookFn = () => void;
|
||||
@ -99,10 +99,7 @@ export abstract class FeedCache<TCached> {
|
||||
}
|
||||
});
|
||||
}
|
||||
return keys
|
||||
.map(a => this.cache.get(a))
|
||||
.filter(a => a)
|
||||
.map(a => unwrap(a));
|
||||
return removeUndefined(keys.map(a => this.cache.get(a)));
|
||||
}
|
||||
|
||||
async set(obj: TCached) {
|
||||
@ -176,18 +173,12 @@ export abstract class FeedCache<TCached> {
|
||||
key: a,
|
||||
}));
|
||||
const start = unixNowMs();
|
||||
const fromCache = await this.table.bulkGet(mapped.filter(a => a.has).map(a => a.key));
|
||||
const fromCacheFiltered = fromCache.filter(a => a !== undefined).map(a => unwrap(a));
|
||||
fromCacheFiltered.forEach(a => {
|
||||
const fromCache = removeUndefined(await this.table.bulkGet(mapped.filter(a => a.has).map(a => a.key)));
|
||||
fromCache.forEach(a => {
|
||||
this.cache.set(this.key(a), a);
|
||||
});
|
||||
this.notifyChange(fromCacheFiltered.map(a => this.key(a)));
|
||||
debug(this.#name)(
|
||||
`Loaded %d/%d in %d ms`,
|
||||
fromCacheFiltered.length,
|
||||
keys.length,
|
||||
(unixNowMs() - start).toLocaleString(),
|
||||
);
|
||||
this.notifyChange(fromCache.map(a => this.key(a)));
|
||||
debug(this.#name)(`Loaded %d/%d in %d ms`, fromCache.length, keys.length, (unixNowMs() - start).toLocaleString());
|
||||
return mapped.filter(a => !a.has).map(a => a.key);
|
||||
}
|
||||
|
||||
|
@ -189,3 +189,7 @@ export async function fetchNip05Pubkey(name: string, domain: string, timeout = 2
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function removeUndefined<T>(v: Array<T | undefined>) {
|
||||
return v.filter(a => a != undefined).map(a => unwrap(a));
|
||||
}
|
||||
|
@ -18,6 +18,13 @@ export interface RelaySettings {
|
||||
write: boolean;
|
||||
}
|
||||
|
||||
export interface OkResponse {
|
||||
ok: boolean;
|
||||
id: string;
|
||||
relay: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of connection stats
|
||||
*/
|
||||
@ -61,7 +68,7 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
|
||||
HasStateChange: boolean = true;
|
||||
IsClosed: boolean;
|
||||
ReconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
EventsCallback: Map<u256, (msg: boolean[]) => void>;
|
||||
EventsCallback: Map<u256, (msg: Array<string | boolean>) => void>;
|
||||
OnConnected?: (wasReconnect: boolean) => void;
|
||||
OnEvent?: (sub: string, e: TaggedNostrEvent) => void;
|
||||
OnEose?: (sub: string) => void;
|
||||
@ -175,11 +182,11 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
|
||||
OnMessage(e: WebSocket.MessageEvent) {
|
||||
this.#activity = unixNowMs();
|
||||
if ((e.data as string).length > 0) {
|
||||
const msg = JSON.parse(e.data as string);
|
||||
const tag = msg[0];
|
||||
const msg = JSON.parse(e.data as string) as Array<string | NostrEvent | boolean>;
|
||||
const tag = msg[0] as string;
|
||||
switch (tag) {
|
||||
case "AUTH": {
|
||||
this.#onAuthAsync(msg[1])
|
||||
this.#onAuthAsync(msg[1] as string)
|
||||
.then(() => this.#sendPendingRaw())
|
||||
.catch(this.#log);
|
||||
this.Stats.EventsReceived++;
|
||||
@ -187,8 +194,8 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
|
||||
break;
|
||||
}
|
||||
case "EVENT": {
|
||||
this.OnEvent?.(msg[1], {
|
||||
...msg[2],
|
||||
this.OnEvent?.(msg[1] as string, {
|
||||
...(msg[2] as NostrEvent),
|
||||
relays: [this.Address],
|
||||
});
|
||||
this.Stats.EventsReceived++;
|
||||
@ -196,17 +203,17 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
|
||||
break;
|
||||
}
|
||||
case "EOSE": {
|
||||
this.OnEose?.(msg[1]);
|
||||
this.OnEose?.(msg[1] as string);
|
||||
break;
|
||||
}
|
||||
case "OK": {
|
||||
// feedback to broadcast call
|
||||
this.#log(`${this.Address} OK: %O`, msg);
|
||||
const id = msg[1];
|
||||
if (this.EventsCallback.has(id)) {
|
||||
const cb = unwrap(this.EventsCallback.get(id));
|
||||
const id = msg[1] as string;
|
||||
const cb = this.EventsCallback.get(id);
|
||||
if (cb) {
|
||||
this.EventsCallback.delete(id);
|
||||
cb(msg);
|
||||
cb(msg as Array<string | boolean>);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -244,17 +251,40 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
|
||||
* Send event on this connection and wait for OK response
|
||||
*/
|
||||
async SendAsync(e: NostrEvent, timeout = 5000) {
|
||||
return new Promise<void>(resolve => {
|
||||
return await new Promise<OkResponse>((resolve, reject) => {
|
||||
if (!this.Settings.write) {
|
||||
resolve();
|
||||
reject(new Error("Not a write relay"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.EventsCallback.has(e.id)) {
|
||||
resolve({
|
||||
ok: false,
|
||||
id: e.id,
|
||||
relay: this.Address,
|
||||
message: "Duplicate request",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
resolve();
|
||||
this.EventsCallback.delete(e.id);
|
||||
resolve({
|
||||
ok: false,
|
||||
id: e.id,
|
||||
relay: this.Address,
|
||||
message: "Timout waiting for OK response",
|
||||
});
|
||||
}, timeout);
|
||||
this.EventsCallback.set(e.id, () => {
|
||||
|
||||
this.EventsCallback.set(e.id, msg => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
const [_, id, accepted, message] = msg;
|
||||
resolve({
|
||||
ok: accepted as boolean,
|
||||
id: id as string,
|
||||
relay: this.Address,
|
||||
message: message as string | undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const req = ["EVENT", e];
|
||||
@ -395,7 +425,7 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
|
||||
resolve();
|
||||
}, 10_000);
|
||||
|
||||
this.EventsCallback.set(authEvent.id, (msg: boolean[]) => {
|
||||
this.EventsCallback.set(authEvent.id, msg => {
|
||||
clearTimeout(t);
|
||||
authCleanup();
|
||||
if (msg.length > 3 && msg[2] === true) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AuthHandler, RelaySettings, ConnectionStateSnapshot } from "./connection";
|
||||
import { AuthHandler, RelaySettings, ConnectionStateSnapshot, OkResponse } from "./connection";
|
||||
import { RequestBuilder } from "./request-builder";
|
||||
import { NoteStore, NoteStoreSnapshotData } from "./note-collection";
|
||||
import { Query } from "./query";
|
||||
@ -87,15 +87,16 @@ export interface SystemInterface {
|
||||
/**
|
||||
* Send an event to all permanent connections
|
||||
* @param ev Event to broadcast
|
||||
* @param cb Callback to handle OkResponse as they arrive
|
||||
*/
|
||||
BroadcastEvent(ev: NostrEvent): void;
|
||||
BroadcastEvent(ev: NostrEvent, cb?: (rsp: OkResponse) => void): Promise<Array<OkResponse>>;
|
||||
|
||||
/**
|
||||
* Connect to a specific relay and send an event and wait for the response
|
||||
* @param relay Relay URL
|
||||
* @param ev Event to send
|
||||
*/
|
||||
WriteOnceToRelay(relay: string, ev: NostrEvent): Promise<void>;
|
||||
WriteOnceToRelay(relay: string, ev: NostrEvent): Promise<OkResponse>;
|
||||
|
||||
/**
|
||||
* Profile cache/loader
|
||||
|
@ -1,8 +1,8 @@
|
||||
import debug from "debug";
|
||||
|
||||
import { unwrap, sanitizeRelayUrl, ExternalStore, FeedCache } from "@snort/shared";
|
||||
import { unwrap, sanitizeRelayUrl, ExternalStore, FeedCache, removeUndefined } from "@snort/shared";
|
||||
import { NostrEvent, TaggedNostrEvent } from "./nostr";
|
||||
import { AuthHandler, Connection, RelaySettings, ConnectionStateSnapshot } from "./connection";
|
||||
import { AuthHandler, Connection, RelaySettings, ConnectionStateSnapshot, OkResponse } from "./connection";
|
||||
import { Query } from "./query";
|
||||
import { NoteCollection, NoteStore, NoteStoreSnapshotData } from "./note-collection";
|
||||
import { BuiltRawReqFilter, RequestBuilder, RequestStrategy } from "./request-builder";
|
||||
@ -354,35 +354,45 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
|
||||
/**
|
||||
* Send events to writable relays
|
||||
*/
|
||||
BroadcastEvent(ev: NostrEvent) {
|
||||
for (const [, s] of this.#sockets) {
|
||||
if (!s.Ephemeral) {
|
||||
s.SendEvent(ev);
|
||||
}
|
||||
async BroadcastEvent(ev: NostrEvent, cb?: (rsp: OkResponse) => void) {
|
||||
const socks = [...this.#sockets.values()].filter(a => !a.Ephemeral && a.Settings.write);
|
||||
const oks = await Promise.all(
|
||||
socks.map(async s => {
|
||||
try {
|
||||
const rsp = await s.SendAsync(ev);
|
||||
cb?.(rsp);
|
||||
return rsp;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return;
|
||||
}),
|
||||
);
|
||||
return removeUndefined(oks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an event to a relay then disconnect
|
||||
*/
|
||||
async WriteOnceToRelay(address: string, ev: NostrEvent) {
|
||||
async WriteOnceToRelay(address: string, ev: NostrEvent): Promise<OkResponse> {
|
||||
const addrClean = sanitizeRelayUrl(address);
|
||||
if (!addrClean) {
|
||||
throw new Error("Invalid relay address");
|
||||
}
|
||||
|
||||
if (this.#sockets.has(addrClean)) {
|
||||
await this.#sockets.get(addrClean)?.SendAsync(ev);
|
||||
const existing = this.#sockets.get(addrClean);
|
||||
if (existing) {
|
||||
return await existing.SendAsync(ev);
|
||||
} else {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
return await new Promise<OkResponse>((resolve, reject) => {
|
||||
const c = new Connection(address, { write: true, read: true }, this.#handleAuth?.bind(this), true);
|
||||
|
||||
const t = setTimeout(reject, 5_000);
|
||||
const t = setTimeout(reject, 10_000);
|
||||
c.OnConnected = async () => {
|
||||
clearTimeout(t);
|
||||
await c.SendAsync(ev);
|
||||
const rsp = await c.SendAsync(ev);
|
||||
c.Close();
|
||||
resolve();
|
||||
resolve(rsp);
|
||||
};
|
||||
c.Connect();
|
||||
});
|
||||
|
@ -1,82 +0,0 @@
|
||||
import { ExternalStore } from "@snort/shared";
|
||||
|
||||
import { SystemSnapshot, SystemInterface, ProfileLoaderService } from ".";
|
||||
import { AuthHandler, ConnectionStateSnapshot, RelaySettings } from "./connection";
|
||||
import { NostrEvent, TaggedNostrEvent } from "./nostr";
|
||||
import { NoteStore, NoteStoreSnapshotData } from "./note-collection";
|
||||
import { Query } from "./query";
|
||||
import { RequestBuilder } from "./request-builder";
|
||||
import { RelayCache } from "./gossip-model";
|
||||
import { QueryOptimizer } from "./query-optimizer";
|
||||
|
||||
export class SystemWorker extends ExternalStore<SystemSnapshot> implements SystemInterface {
|
||||
#port: MessagePort;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if ("SharedWorker" in window) {
|
||||
const worker = new SharedWorker("/system.js");
|
||||
this.#port = worker.port;
|
||||
this.#port.onmessage = m => this.#onMessage(m);
|
||||
} else {
|
||||
throw new Error("SharedWorker is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
Fetch(req: RequestBuilder, cb?: (evs: Array<TaggedNostrEvent>) => void): Promise<NoteStoreSnapshotData> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
get ProfileLoader(): ProfileLoaderService {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
get RelayCache(): RelayCache {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
get QueryOptimizer(): QueryOptimizer {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
HandleAuth?: AuthHandler;
|
||||
|
||||
get Sockets(): ConnectionStateSnapshot[] {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
Query<T extends NoteStore>(type: new () => T, req: RequestBuilder | null): Query {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
CancelQuery(sub: string): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
GetQuery(sub: string): Query | undefined {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
ConnectToRelay(address: string, options: RelaySettings): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
DisconnectRelay(address: string): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
BroadcastEvent(ev: NostrEvent): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
WriteOnceToRelay(relay: string, ev: NostrEvent): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
takeSnapshot(): SystemSnapshot {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
#onMessage(e: MessageEvent<any>) {
|
||||
console.debug(e);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { unwrap } from "@snort/shared";
|
||||
import { removeUndefined } from "@snort/shared";
|
||||
|
||||
import {
|
||||
CashuRegex,
|
||||
@ -230,8 +230,8 @@ export function transformText(body: string, tags: Array<Array<string>>) {
|
||||
fragments = extractCashuTokens(fragments);
|
||||
fragments = extractCustomEmoji(fragments, tags);
|
||||
fragments = extractMarkdownCode(fragments);
|
||||
fragments = fragments
|
||||
.map(a => {
|
||||
fragments = removeUndefined(
|
||||
fragments.map(a => {
|
||||
if (typeof a === "string") {
|
||||
if (a.length > 0) {
|
||||
return { type: "text", content: a } as ParsedFragment;
|
||||
@ -239,8 +239,7 @@ export function transformText(body: string, tags: Array<Array<string>>) {
|
||||
} else {
|
||||
return a;
|
||||
}
|
||||
})
|
||||
.filter(a => a)
|
||||
.map(a => unwrap(a));
|
||||
}),
|
||||
);
|
||||
return fragments as Array<ParsedFragment>;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user