feat: note publishing progress

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

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