feat: note publishing progress
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Kieran 2023-10-11 15:41:36 +01:00
parent c239fba3df
commit 0e4a040750
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
22 changed files with 438 additions and 351 deletions

View File

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

View File

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

View File

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

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

View File

@ -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,60 +307,109 @@ 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;
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()}
function noteCreatorAdvanced() {
return (
<>
<button className="secondary" onClick={loadPreview}>
<FormattedMessage defaultMessage="Toggle Preview" />
</button>
<div>
<h4>
<FormattedMessage defaultMessage="Custom Relays" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" />
</p>
{renderRelayCustomisation()}
</div>
)}
<div className="flex-column g8">
<h4>
<FormattedMessage defaultMessage="Zap Splits" />
</h4>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." />
<div className="flex-column g8">
{[...(note.zapSplits ?? [])].map((v, i, arr) => (
<div className="flex f-center g8">
<div className="flex-column f-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" />
</h4>
<input
type="text"
value={v.value}
onChange={e =>
note.update(
v => (v.zapSplits = arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))),
)
}
placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address" })}
/>
</div>
<div className="flex-column f-1 g4">
<h4>
<FormattedMessage defaultMessage="Weight" />
</h4>
<input
type="number"
min={0}
value={v.weight}
onChange={e =>
note.update(
v =>
(v.zapSplits = arr.map((vv, ii) =>
ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
)),
)
}
/>
</div>
<div className="flex-column f-shrink g4">
<div>&nbsp;</div>
<Icon
name="close"
onClick={() => note.update(v => (v.zapSplits = (v.zapSplits ?? []).filter((_v, ii) => ii !== i)))}
/>
</div>
</div>
))}
<button
type="button"
onClick={() =>
note.update(v => (v.zapSplits = [...(v.zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
}>
<FormattedMessage defaultMessage="Add" />
</button>
</div>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured" />
</span>
</div>
<div className="flex-column g8">
<h4>
<FormattedMessage defaultMessage="Sensitive Content" />
</h4>
<FormattedMessage defaultMessage="Users must accept the content warning to show the content of your note." />
<input
className="w-max"
type="text"
value={note.sensitive}
onChange={e => note.update(v => (v.sensitive = e.target.value))}
maxLength={50}
minLength={1}
placeholder={formatMessage({
defaultMessage: "Reason",
})}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</span>
</div>
</>
);
}
function noteCreatorFooter() {
return (
<div className="flex f-space">
<div className="flex g8">
<ProfileImage
@ -409,105 +438,86 @@ export function NoteCreator() {
</AsyncButton>
</div>
</div>
{note.error && <span className="error">{note.error}</span>}
{note.advanced && (
<>
<button className="secondary" onClick={loadPreview}>
<FormattedMessage defaultMessage="Toggle Preview" />
</button>
<div>
<h4>
<FormattedMessage defaultMessage="Custom Relays" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" />
</p>
{renderRelayCustomisation()}
</div>
<div className="flex-column g8">
<h4>
<FormattedMessage defaultMessage="Zap Splits" />
</h4>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." />
<div className="flex-column g8">
{[...(note.zapSplits ?? [])].map((v, i, arr) => (
<div className="flex f-center g8">
<div className="flex-column f-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" />
</h4>
<input
type="text"
value={v.value}
onChange={e =>
note.update(
v => (v.zapSplits = arr.map((vv, ii) => (ii === i ? { ...vv, value: e.target.value } : vv))),
)
}
placeholder={formatMessage({ defaultMessage: "npub / nprofile / nostr address" })}
/>
</div>
<div className="flex-column f-1 g4">
<h4>
<FormattedMessage defaultMessage="Weight" />
</h4>
<input
type="number"
min={0}
value={v.weight}
onChange={e =>
note.update(
v =>
(v.zapSplits = arr.map((vv, ii) =>
ii === i ? { ...vv, weight: Number(e.target.value) } : vv,
)),
)
}
/>
</div>
<div className="flex-column f-shrink g4">
<div>&nbsp;</div>
<Icon
name="close"
onClick={() => note.update(v => (v.zapSplits = (v.zapSplits ?? []).filter((_v, ii) => ii !== i)))}
/>
</div>
</div>
))}
<button
type="button"
onClick={() =>
note.update(v => (v.zapSplits = [...(v.zapSplits ?? []), { type: "pubkey", value: "", weight: 1 }]))
}>
<FormattedMessage defaultMessage="Add" />
</button>
</div>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this, you may still receive some zaps as if zap splits was not configured" />
</span>
</div>
<div className="flex-column g8">
<h4>
<FormattedMessage defaultMessage="Sensitive Content" />
</h4>
<FormattedMessage defaultMessage="Users must accept the content warning to show the content of your note." />
<input
className="w-max"
type="text"
value={note.sensitive}
onChange={e => note.update(v => (v.sensitive = e.target.value))}
maxLength={50}
minLength={1}
placeholder={formatMessage({
defaultMessage: "Reason",
})}
);
}
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);
}
}}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" />
</span>
{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>
);
}

View File

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

View File

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

View File

@ -1,7 +1,3 @@
.preferences small {
color: var(--font-secondary-color);
}
.preferences select {
min-width: 100px;
}

View File

@ -57,8 +57,3 @@
.settings .actions {
margin-top: 16px;
}
.settings small {
font-size: 14px;
color: var(--font-secondary-color);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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