reorganize code into smaller files & dirs

This commit is contained in:
Martti Malmi
2024-01-04 15:48:19 +02:00
parent 5ea2eb711f
commit afa6d39a56
321 changed files with 671 additions and 671 deletions

View File

@ -0,0 +1,122 @@
.note-creator {
border: 1px solid transparent;
border-radius: 12px;
box-shadow: 0px 0px 6px 1px rgba(182, 108, 156, 0.3);
background:
linear-gradient(var(--gray-superdark), var(--gray-superdark)) padding-box,
linear-gradient(90deg, #ef9644, #fd7c49, #ff5e58, #ff3b70, #ff088e, #eb00b1, #c31ed5, #7b41f6) border-box;
}
.note-creator-modal .modal-body > div {
display: flex;
flex-direction: column;
gap: 16px;
}
.note-creator-modal .note.card {
padding: 0;
border: none;
min-height: unset;
}
.note-creator-modal .note.card.note-quote {
border: 1px solid var(--gray);
padding: 8px 12px;
}
.note-creator-modal h4 {
font-size: 11px;
font-weight: 600;
letter-spacing: 1.21px;
text-transform: uppercase;
color: var(--gray-light);
margin: 0;
}
.note-creator-relay {
background-color: var(--gray-dark);
border-radius: 12px;
}
.note-creator textarea {
border: none;
outline: none;
resize: none;
padding: 0;
border-radius: 0;
margin: 8px 12px;
background-color: var(--gray-superdark);
min-height: 100px;
width: stretch;
width: -webkit-fill-available;
width: -moz-available;
max-height: 210px;
}
.note-creator textarea::placeholder {
color: var(--font-secondary-color);
font-size: var(--font-size);
line-height: 24px;
}
.note-creator.poll textarea {
min-height: 120px;
}
.note-creator .error {
position: absolute;
left: 16px;
bottom: 12px;
color: var(--error);
margin-right: 12px;
font-size: 16px;
}
.note-creator-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
}
.note-creator-icon.pfp .avatar {
width: 32px;
height: 32px;
}
.light .note-creator textarea {
background-color: var(--gray-superdark);
}
.light .note-creator {
box-shadow: 0px 0px 6px 1px rgba(182, 108, 156, 0.3);
background:
linear-gradient(var(--gray-superdark), var(--gray-superdark)) padding-box,
linear-gradient(90deg, #ef9644, #fd7c49, #ff5e58, #ff3b70, #ff088e, #eb00b1, #c31ed5, #7b41f6) border-box;
}
.note-creator-modal .rti--container {
background-color: unset !important;
box-shadow: unset !important;
border: 2px solid var(--border-color) !important;
border-radius: 12px !important;
padding: 4px 8px !important;
}
.note-creator-modal .rti--tag {
color: black !important;
padding: 4px 10px !important;
border-radius: 12px !important;
display: unset !important;
}
.note-creator-modal .rti--input {
width: 100% !important;
border: unset !important;
}
.note-creator-modal .rti--tag button {
padding: 0 0 0 var(--rti-s);
}

View File

@ -0,0 +1,691 @@
import "./NoteCreator.css";
import { FormattedMessage, useIntl } from "react-intl";
import { EventBuilder, EventKind, NostrLink, NostrPrefix, TaggedNostrEvent, tryParseNostrLink } from "@snort/system";
import classNames from "classnames";
import { TagsInput } from "react-tag-input-component";
import Icon from "@/Components/Icons/Icon";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { appendDedupe, openFile, trackEvent } from "@/Utils";
import Textarea from "@/Components/Textarea/Textarea";
import Modal from "@/Components/Modal/Modal";
import ProfileImage from "@/Components/User/ProfileImage";
import useFileUpload from "@/Utils/Upload";
import Note from "@/Components/Event/Note";
import { ClipboardEventHandler, DragEvent, useEffect } from "react";
import useLogin from "@/Hooks/useLogin";
import AsyncButton from "@/Components/Button/AsyncButton";
import { AsyncIcon } from "@/Components/Button/AsyncIcon";
import { fetchNip05Pubkey, unixNow } from "@snort/shared";
import { ZapTarget } from "@/Utils/Zapper";
import { useNoteCreator } from "@/State/NoteCreator";
import FileUploadProgress from "../FileUpload";
import { ToggleSwitch } from "@/Components/Icons/Toggle";
import { sendEventToRelays } from "@/Components/Event/Create/util";
import { TrendingHashTagsLine } from "@/Components/Event/Create/TrendingHashTagsLine";
import { Toastore } from "@/Components/Toaster/Toaster";
import { OkResponseRow } from "./OkResponseRow";
import CloseButton from "@/Components/Button/CloseButton";
import { GetPowWorker } from "@/Utils/wasm";
export function NoteCreator() {
const { formatMessage } = useIntl();
const uploader = useFileUpload();
const login = useLogin(s => ({ relays: s.relays, publicKey: s.publicKey, pow: s.appData.item.preferences.pow }));
const { system, publisher: pub } = useEventPublisher();
const publisher = login.pow ? pub?.pow(login.pow, GetPowWorker()) : pub;
const note = useNoteCreator();
const relays = login.relays;
useEffect(() => {
const draft = localStorage.getItem("msgDraft");
if (draft) {
note.update(n => (n.note = draft));
}
}, []);
async function buildNote() {
try {
note.update(v => (v.error = ""));
if (note && publisher) {
let extraTags: Array<Array<string>> | undefined;
if (note.zapSplits) {
const parsedSplits = [] as Array<ZapTarget>;
for (const s of note.zapSplits) {
if (s.value.startsWith(NostrPrefix.PublicKey) || s.value.startsWith(NostrPrefix.Profile)) {
const link = tryParseNostrLink(s.value);
if (link) {
parsedSplits.push({ ...s, value: link.id });
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Failed to parse zap split: {input}",
id: "sZQzjQ",
},
{
input: s.value,
},
),
);
}
} else if (s.value.includes("@")) {
const [name, domain] = s.value.split("@");
const pubkey = await fetchNip05Pubkey(name, domain);
if (pubkey) {
parsedSplits.push({ ...s, value: pubkey });
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Failed to parse zap split: {input}",
id: "sZQzjQ",
},
{
input: s.value,
},
),
);
}
} else {
throw new Error(
formatMessage(
{
defaultMessage: "Invalid zap split: {input}",
id: "8Y6bZQ",
},
{
input: s.value,
},
),
);
}
}
extraTags = parsedSplits.map(v => ["zap", v.value, "", String(v.weight)]);
}
if (note.sensitive) {
extraTags ??= [];
extraTags.push(["content-warning", note.sensitive]);
}
const kind = note.pollOptions ? EventKind.Polls : EventKind.TextNote;
if (note.pollOptions) {
extraTags ??= [];
extraTags.push(...note.pollOptions.map((a, i) => ["poll_option", i.toString(), a]));
}
if (note.hashTags.length > 0) {
extraTags ??= [];
extraTags.push(...note.hashTags.map(a => ["t", a.toLowerCase()]));
}
// add quote repost
if (note.quote) {
if (!note.note.endsWith("\n")) {
note.note += "\n";
}
const link = NostrLink.fromEvent(note.quote);
note.note += `nostr:${link.encode(CONFIG.eventLinkPrefix)}`;
const quoteTag = link.toEventTag();
if (quoteTag) {
extraTags ??= [];
if (quoteTag[0] === "e") {
quoteTag[0] = "q"; // how to 'q' tag replacable events?
}
extraTags.push(quoteTag);
}
}
const hk = (eb: EventBuilder) => {
extraTags?.forEach(t => eb.tag(t));
note.extraTags?.forEach(t => eb.tag(t));
eb.kind(kind);
return eb;
};
const ev = note.replyTo
? await publisher.reply(note.replyTo, note.note, hk)
: await publisher.note(note.note, hk);
return ev;
}
} catch (e) {
note.update(v => {
if (e instanceof Error) {
v.error = e.message;
} else {
v.error = e as string;
}
});
}
}
async function sendNote() {
const ev = await buildNote();
if (ev) {
let props: Record<string, boolean> | undefined = undefined;
if (ev.tags.find(a => a[0] === "content-warning")) {
props ??= {};
props["content-warning"] = true;
}
if (ev.tags.find(a => a[0] === "poll_option")) {
props ??= {};
props["poll"] = true;
}
if (ev.tags.find(a => a[0] === "zap")) {
props ??= {};
props["zap-split"] = true;
}
trackEvent("PostNote", props);
const events = (note.otherEvents ?? []).concat(ev);
events.map(a =>
sendEventToRelays(system, a, note.selectedCustomRelays, r => {
if (CONFIG.noteCreatorToast) {
r.forEach(rr => {
Toastore.push({
element: c => <OkResponseRow rsp={rr} close={c} />,
expire: unixNow() + (rr.ok ? 5 : 55555),
});
});
}
}),
);
note.update(n => n.reset());
localStorage.removeItem("msgDraft");
}
}
async function attachFile() {
try {
const file = await openFile();
if (file) {
uploadFile(file);
}
} catch (e) {
note.update(v => {
if (e instanceof Error) {
v.error = e.message;
} else {
v.error = e as string;
}
});
}
}
async function uploadFile(file: File) {
try {
if (file) {
const rx = await uploader.upload(file, file.name);
note.update(v => {
if (rx.header) {
const link = `nostr:${new NostrLink(NostrPrefix.Event, rx.header.id, rx.header.kind).encode(
CONFIG.eventLinkPrefix,
)}`;
v.note = `${v.note ? `${v.note}\n` : ""}${link}`;
v.otherEvents = [...(v.otherEvents ?? []), rx.header];
} else if (rx.url) {
v.note = `${v.note ? `${v.note}\n` : ""}${rx.url}`;
if (rx.metadata) {
v.extraTags ??= [];
const imeta = ["imeta", `url ${rx.url}`];
if (rx.metadata.blurhash) {
imeta.push(`blurhash ${rx.metadata.blurhash}`);
}
if (rx.metadata.width && rx.metadata.height) {
imeta.push(`dim ${rx.metadata.width}x${rx.metadata.height}`);
}
if (rx.metadata.hash) {
imeta.push(`x ${rx.metadata.hash}`);
}
v.extraTags.push(imeta);
}
} else if (rx?.error) {
v.error = rx.error;
}
});
}
} catch (e) {
note.update(v => {
if (e instanceof Error) {
v.error = e.message;
} else {
v.error = e as string;
}
});
}
}
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
const { value } = ev.target;
note.update(n => (n.note = value));
localStorage.setItem("msgDraft", value);
}
function cancel() {
note.update(v => {
v.show = false;
v.reset();
});
}
async function onSubmit(ev: React.MouseEvent) {
ev.stopPropagation();
await sendNote();
}
async function loadPreview() {
if (note.preview) {
note.update(v => (v.preview = undefined));
} else if (publisher) {
const tmpNote = await buildNote();
trackEvent("PostNotePreview");
note.update(v => (v.preview = tmpNote));
}
}
function getPreviewNote() {
if (note.preview) {
return (
<Note
data={note.preview as TaggedNostrEvent}
related={[]}
options={{
showContextMenu: false,
showFooter: false,
canClick: false,
showTime: false,
}}
/>
);
}
}
function renderPollOptions() {
if (note.pollOptions) {
return (
<>
<h4>
<FormattedMessage defaultMessage="Poll Options" id="vhlWFg" />
</h4>
{note.pollOptions?.map((a, i) => (
<div className="form-group w-max" key={`po-${i}`}>
<div>
<FormattedMessage defaultMessage="Option: {n}" id="mfe8RW" values={{ n: i + 1 }} />
</div>
<div>
<input type="text" value={a} onChange={e => changePollOption(i, e.target.value)} />
{i > 1 && <CloseButton className="ml5" onClick={() => removePollOption(i)} />}
</div>
</div>
))}
<button onClick={() => note.update(v => (v.pollOptions = [...(note.pollOptions ?? []), ""]))}>
<Icon name="plus" size={14} />
</button>
</>
);
}
}
function changePollOption(i: number, v: string) {
if (note.pollOptions) {
const copy = [...note.pollOptions];
copy[i] = v;
note.update(v => (v.pollOptions = copy));
}
}
function removePollOption(i: number) {
if (note.pollOptions) {
const copy = [...note.pollOptions];
copy.splice(i, 1);
note.update(v => (v.pollOptions = copy));
}
}
function renderRelayCustomisation() {
return (
<div className="flex flex-col g8">
{Object.keys(relays.item || {})
.filter(el => relays.item[el].write)
.map((r, i, a) => (
<div className="p flex justify-between note-creator-relay" key={r}>
<div>{r}</div>
<div>
<input
type="checkbox"
checked={!note.selectedCustomRelays || note.selectedCustomRelays.includes(r)}
onChange={e => {
note.update(
v =>
(v.selectedCustomRelays =
// set false if all relays selected
e.target.checked &&
note.selectedCustomRelays &&
note.selectedCustomRelays.length == a.length - 1
? undefined
: // otherwise return selectedCustomRelays with target relay added / removed
a.filter(el =>
el === r
? e.target.checked
: !note.selectedCustomRelays || note.selectedCustomRelays.includes(el),
)),
);
}}
/>
</div>
</div>
))}
</div>
);
}
/*function listAccounts() {
return LoginStore.getSessions().map(a => (
<MenuItem
onClick={ev => {
ev.stopPropagation = true;
LoginStore.switchAccount(a);
}}>
<ProfileImage pubkey={a} link={""} />
</MenuItem>
));
}*/
function noteCreatorAdvanced() {
return (
<>
<div>
<h4>
<FormattedMessage defaultMessage="Custom Relays" id="EcZF24" />
</h4>
<p>
<FormattedMessage defaultMessage="Send note to a subset of your write relays" id="th5lxp" />
</p>
{renderRelayCustomisation()}
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Zap Splits" id="5CB6zB" />
</h4>
<FormattedMessage defaultMessage="Zaps on this note will be split to the following users." id="LwYmVi" />
<div className="flex flex-col g8">
{[...(note.zapSplits ?? [])].map((v: ZapTarget, i, arr) => (
<div className="flex items-center g8" key={`${v.name}-${v.value}`}>
<div className="flex flex-col flex-4 g4">
<h4>
<FormattedMessage defaultMessage="Recipient" id="8Rkoyb" />
</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", id: "WvGmZT" })}
/>
</div>
<div className="flex flex-col flex-1 g4">
<h4>
<FormattedMessage defaultMessage="Weight" id="zCb8fX" />
</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 flex-col s 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" id="2/2yg+" />
</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"
id="6bgpn+"
/>
</span>
</div>
<div className="flex flex-col g8">
<h4>
<FormattedMessage defaultMessage="Sensitive Content" id="bQdA2k" />
</h4>
<FormattedMessage
defaultMessage="Users must accept the content warning to show the content of your note."
id="UUPFlt"
/>
<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",
id: "AkCxS/",
})}
/>
<span className="warning">
<FormattedMessage defaultMessage="Not all clients support this yet" id="gXgY3+" />
</span>
</div>
</>
);
}
function noteCreatorFooter() {
return (
<div className="flex justify-between">
<div className="flex items-center g8">
<ProfileImage
pubkey={login.publicKey ?? ""}
className="note-creator-icon"
link=""
showUsername={false}
showFollowDistance={false}
showProfileCard={false}
/>
{note.pollOptions === undefined && !note.replyTo && (
<AsyncIcon
iconName="list"
iconSize={24}
onClick={() => note.update(v => (v.pollOptions = ["A", "B"]))}
className={classNames("note-creator-icon", { active: note.pollOptions !== undefined })}
/>
)}
<AsyncIcon iconName="image-plus" iconSize={24} onClick={attachFile} className="note-creator-icon" />
<AsyncIcon
iconName="settings-04"
iconSize={24}
onClick={() => note.update(v => (v.advanced = !v.advanced))}
className={classNames("note-creator-icon", { active: note.advanced })}
/>
<span className="sm:inline hidden">
<FormattedMessage defaultMessage="Preview" id="TJo5E6" />
</span>
<ToggleSwitch
onClick={() => loadPreview()}
size={40}
className={classNames({ active: Boolean(note.preview) })}
/>
</div>
<div className="flex g8">
<button className="secondary" onClick={cancel}>
<FormattedMessage defaultMessage="Cancel" id="47FYwb" />
</button>
<AsyncButton onClick={onSubmit} className="primary">
{note.replyTo ? (
<FormattedMessage defaultMessage="Reply" id="9HU8vw" />
) : (
<FormattedMessage defaultMessage="Send" id="9WRlF4" />
)}
</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);
}
}
};
const handleDragOver = (event: DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
};
const handleDragLeave = (event: DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
};
const handleDrop = (event: DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
const droppedFiles = Array.from(event.dataTransfer.files);
droppedFiles.forEach(async file => {
await uploadFile(file);
});
};
function noteCreatorForm() {
return (
<>
{note.replyTo && (
<>
<h4>
<FormattedMessage defaultMessage="Reply To" id="8ED/4u" />
</h4>
<Note
data={note.replyTo}
related={[]}
options={{
showFooter: false,
showContextMenu: false,
showProfileCard: false,
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
}}
/>
</>
)}
{note.quote && (
<>
<h4>
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</h4>
<Note
data={note.quote}
related={[]}
options={{
showFooter: false,
showContextMenu: false,
showTime: false,
canClick: false,
showMedia: false,
longFormPreview: true,
}}
/>
</>
)}
{note.preview && getPreviewNote()}
{!note.preview && (
<>
<div onPaste={handlePaste} className={classNames("note-creator", { poll: Boolean(note.pollOptions) })}>
<Textarea
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
autoFocus
className={classNames("textarea", { "textarea--focused": note.active })}
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 flex-col g4">
<TagsInput
value={note.hashTags}
onChange={e => note.update(s => (s.hashTags = e))}
placeHolder={formatMessage({
defaultMessage: "Add up to 4 hashtags",
id: "AIgmDy",
})}
separators={["Enter", ","]}
/>
{note.hashTags.length > 4 && (
<small className="warning">
<FormattedMessage defaultMessage="Try to use less than 5 hashtags to stay on topic 🙏" id="d8gpCh" />
</small>
)}
<TrendingHashTagsLine onClick={t => note.update(s => (s.hashTags = appendDedupe(s.hashTags, [t])))} />
</div>
</>
)}
{uploader.progress.length > 0 && <FileUploadProgress progress={uploader.progress} />}
{noteCreatorFooter()}
{note.error && <span className="error">{note.error}</span>}
{note.advanced && noteCreatorAdvanced()}
</>
);
}
function reset() {
note.update(v => {
v.show = false;
});
}
if (!note.show) return null;
return (
<Modal
id="note-creator"
bodyClassName="modal-body flex flex-col gap-4"
className="note-creator-modal"
onClose={reset}>
{noteCreatorForm()}
</Modal>
);
}

View File

@ -0,0 +1,84 @@
import { useRef, useMemo } from "react";
import { useLocation } from "react-router-dom";
import classNames from "classnames";
import { isFormElement } from "@/Utils";
import useKeyboardShortcut from "@/Hooks/useKeyboardShortcut";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Components/Icons/Icon";
import { useNoteCreator } from "@/State/NoteCreator";
import { NoteCreator } from "./NoteCreator";
import { FormattedMessage } from "react-intl";
export const NoteCreatorButton = ({
className,
alwaysShow,
showText,
}: {
className?: string;
alwaysShow?: boolean;
showText?: boolean;
}) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const location = useLocation();
const { readonly } = useLogin(s => ({ readonly: s.readonly }));
const { show, replyTo, update } = useNoteCreator(v => ({ show: v.show, replyTo: v.replyTo, update: v.update }));
useKeyboardShortcut("n", event => {
// if event happened in a form element, do nothing, otherwise focus on search input
if (event.target && !isFormElement(event.target as HTMLElement)) {
event.preventDefault();
if (buttonRef.current) {
buttonRef.current.click();
}
}
});
const shouldHideNoteCreator = useMemo(() => {
if (alwaysShow) {
return false;
}
const isReply = replyTo && show;
const hideOn = [
"/settings",
"/messages",
"/new",
"/login",
"/donate",
"/e",
"/nevent",
"/note1",
"/naddr",
"/subscribe",
];
return (readonly || hideOn.some(a => location.pathname.startsWith(a))) && !isReply;
}, [location, readonly]);
return (
<>
{!shouldHideNoteCreator && (
<button
ref={buttonRef}
className={classNames(
"aspect-square flex flex-row items-center primary rounded-full",
{ "xl:aspect-auto": showText },
className,
)}
onClick={() =>
update(v => {
v.replyTo = undefined;
v.show = true;
})
}>
<Icon name="plus" size={16} />
{showText && (
<span className="ml-2 hidden xl:inline">
<FormattedMessage defaultMessage="New Note" id="2mcwT8" />
</span>
)}
</button>
)}
<NoteCreator key="global-note-creator" />
</>
);
};

View File

@ -0,0 +1,64 @@
import AsyncButton from "@/Components/Button/AsyncButton";
import IconButton from "@/Components/Button/IconButton";
import useEventPublisher from "@/Hooks/useEventPublisher";
import useLogin from "@/Hooks/useLogin";
import Icon from "@/Components/Icons/Icon";
import { removeRelay } from "@/Utils/Login";
import { saveRelays } from "@/Pages/settings/Relays";
import { getRelayName } from "@/Utils";
import { unwrap, sanitizeRelayUrl } from "@snort/shared";
import { OkResponse } from "@snort/system";
import { useState } from "react";
import { useIntl } from "react-intl";
export function OkResponseRow({ rsp, close }: { rsp: OkResponse; close: () => void }) {
const [r, setResult] = useState(rsp);
const { formatMessage } = useIntl();
const { publisher, system } = useEventPublisher();
const login = useLogin();
async function removeRelayFromResult(r: OkResponse) {
if (publisher) {
removeRelay(login, unwrap(sanitizeRelayUrl(r.relay)));
await saveRelays(system, publisher, login.relays.item);
}
close();
}
async function retryPublish(r: OkResponse) {
const rsp = await system.WriteOnceToRelay(unwrap(sanitizeRelayUrl(r.relay)), r.event);
setResult(rsp);
}
return (
<div className="flex items-center g16">
<div className="flex flex-col grow g4">
<b>{getRelayName(r.relay)}</b>
{r.message && <small>{r.message}</small>}
</div>
{!r.ok && (
<div className="flex g8">
<AsyncButton
onClick={() => retryPublish(r)}
className="p4 br-compact flex items-center secondary"
title={formatMessage({
defaultMessage: "Retry publishing",
id: "9kSari",
})}>
<Icon name="refresh-ccw-01" />
</AsyncButton>
<AsyncButton
onClick={() => removeRelayFromResult(r)}
className="p4 br-compact flex items-center secondary"
title={formatMessage({
defaultMessage: "Remove from my relays",
id: "UJTWqI",
})}>
<Icon name="trash-01" className="trash-icon" />
</AsyncButton>
</div>
)}
<IconButton icon={{ name: "x" }} onClick={close} />
</div>
);
}

View File

@ -0,0 +1,36 @@
import { useLocale } from "@/IntlProvider";
import NostrBandApi from "@/External/NostrBand";
import { FormattedMessage } from "react-intl";
import useCachedFetch from "@/Hooks/useCachedFetch";
import { ErrorOrOffline } from "@/Components/ErrorOrOffline";
export function TrendingHashTagsLine(props: { onClick: (tag: string) => void }) {
const { lang } = useLocale();
const api = new NostrBandApi();
const trendingHashtagsUrl = api.trendingHashtagsUrl(lang);
const storageKey = `nostr-band-${trendingHashtagsUrl}`;
const { data: hashtags, isLoading, error } = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags);
if (error && !hashtags) return <ErrorOrOffline error={error} className="p" />;
if (isLoading || hashtags.length === 0) return null;
return (
<div className="flex flex-col g4">
<small>
<FormattedMessage defaultMessage="Popular Hashtags" id="ddd3JX" />
</small>
<div className="flex g4 flex-wrap">
{hashtags.slice(0, 5).map(a => (
<span
key={a.hashtag}
className="px-2 py-1 bg-dark rounded-full pointer nowrap"
onClick={() => props.onClick(a.hashtag)}>
#{a.hashtag}
</span>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { NostrEvent, OkResponse, SystemInterface } from "@snort/system";
import { removeUndefined } from "@snort/shared";
export async function sendEventToRelays(
system: SystemInterface,
ev: NostrEvent,
customRelays?: Array<string>,
setResults?: (x: Array<OkResponse>) => void,
) {
if (customRelays) {
system.HandleEvent({ ...ev, relays: [] });
return removeUndefined(
await Promise.all(
customRelays.map(async r => {
try {
return await system.WriteOnceToRelay(r, ev);
} catch (e) {
console.error(e);
}
}),
),
);
} else {
const responses: OkResponse[] = await system.BroadcastEvent(ev);
setResults?.(responses);
return responses;
}
}

View File

@ -0,0 +1,15 @@
import Progress from "@/Components/Progress/Progress";
import { UploadProgress } from "@/Utils/Upload";
export default function FileUploadProgress({ progress }: { progress: Array<UploadProgress> }) {
return (
<div className="flex flex-col g8">
{progress.map(p => (
<div key={p.id} className="flex flex-col g2" id={p.id}>
{p.file.name}
<Progress value={p.progress} status={p.stage} />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,23 @@
import messages from "../messages";
import { useState } from "react";
import { FormattedMessage } from "react-intl";
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
const [show, setShow] = useState(false);
return show ? (
children
) : (
<div className="card note hidden-note">
<div className="header">
<p>
<FormattedMessage defaultMessage="This note has been muted" id="qfmMQh" />
</p>
<button type="button" onClick={() => setShow(true)}>
<FormattedMessage {...messages.Show} />
</button>
</div>
</div>
);
};
export default HiddenNote;

View File

@ -0,0 +1,67 @@
.long-form-note p {
font-family: Georgia;
line-height: 1.7;
}
.long-form-note hr {
border: 0;
height: 1px;
background-color: var(--gray);
margin: 5px 0px;
}
.long-form-note .reading {
border: 1px dashed var(--highlight);
}
.long-form-note .header-image {
height: 360px;
background: var(--img);
background-position: center;
background-size: cover;
}
.long-form-note h1 {
font-size: 32px;
font-weight: 700;
line-height: 40px; /* 125% */
margin: 0;
}
.long-form-note small {
font-weight: 400;
line-height: 24px; /* 150% */
}
.long-form-note img:not(.custom-emoji),
.long-form-note video,
.long-form-note iframe,
.long-form-note audio {
width: 100%;
display: block;
}
.long-form-note iframe,
.long-form-note video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
.long-form-note .footer {
display: flex;
}
.long-form-note .footer .footer-reactions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: auto;
gap: 48px;
}
@media (min-width: 720px) {
.long-form-note .footer .footer-reactions {
margin-left: 0;
}
}

View File

@ -0,0 +1,168 @@
import "./LongFormText.css";
import React, { CSSProperties, useCallback, useRef, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
import { findTag } from "@/Utils";
import Text from "@/Components/Text/Text";
import { Markdown } from "./Markdown";
import useImgProxy from "@/Hooks/useImgProxy";
import ProfilePreview from "@/Components/User/ProfilePreview";
import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime";
import classNames from "classnames";
interface LongFormTextProps {
ev: TaggedNostrEvent;
isPreview: boolean;
related: ReadonlyArray<TaggedNostrEvent>;
onClick?: () => void;
truncate?: boolean;
}
const TEXT_TRUNCATE_LENGTH = 400;
export function LongFormText(props: LongFormTextProps) {
const title = findTag(props.ev, "title");
const summary = findTag(props.ev, "summary");
const image = findTag(props.ev, "image");
const { proxy } = useImgProxy();
const [reading, setReading] = useState(false);
const [showMore, setShowMore] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { reactions, reposts, zaps } = useEventReactions(NostrLink.fromEvent(props.ev), props.related);
function previewText() {
return (
<Text
id={props.ev.id}
content={props.ev.content}
tags={props.ev.tags}
creator={props.ev.pubkey}
truncate={props.isPreview ? 250 : undefined}
disableLinkPreview={props.isPreview}
/>
);
}
function readTime() {
const wpm = 225;
const words = props.ev.content.trim().split(/\s+/).length;
return {
words,
wpm,
mins: Math.ceil(words / wpm),
};
}
const readAsync = async (text: string) => {
return await new Promise<void>(resolve => {
const ut = new SpeechSynthesisUtterance(text);
ut.onend = () => {
resolve();
};
window.speechSynthesis.speak(ut);
});
};
const readArticle = useCallback(async () => {
if (ref.current && !reading) {
setReading(true);
const paragraphs = ref.current.querySelectorAll("p,h1,h2,h3,h4,h5,h6");
for (const p of paragraphs) {
if (p.textContent) {
p.classList.add("reading");
await readAsync(p.textContent);
p.classList.remove("reading");
}
}
setReading(false);
}
}, [ref, reading]);
const stopReading = () => {
setReading(false);
if (ref.current) {
const paragraphs = ref.current.querySelectorAll("p,h1,h2,h3,h4,h5,h6");
paragraphs.forEach(a => a.classList.remove("reading"));
window.speechSynthesis.cancel();
}
};
const ToggleShowMore = () => (
<a
className="highlight cursor-pointer"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
const shouldTruncate = props.truncate && props.ev.content.length > TEXT_TRUNCATE_LENGTH;
const content = shouldTruncate && !showMore ? props.ev.content.slice(0, TEXT_TRUNCATE_LENGTH) : props.ev.content;
function fullText() {
return (
<>
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
<hr />
<div className="flex g8">
<div>
<FormattedMessage
defaultMessage="{n} mins to read"
id="zm6qS1"
values={{
n: <FormattedNumber value={readTime().mins} />,
}}
/>
</div>
<div></div>
{!reading && (
<div className="pointer" onClick={() => readArticle()}>
<FormattedMessage defaultMessage="Listen to this article" id="nihgfo" />
</div>
)}
{reading && (
<div className="pointer" onClick={() => stopReading()}>
<FormattedMessage defaultMessage="Stop listening" id="U1aPPi" />
</div>
)}
</div>
<hr />
{shouldTruncate && showMore && <ToggleShowMore />}
<Markdown content={content} tags={props.ev.tags} ref={ref} />
{shouldTruncate && !showMore && <ToggleShowMore />}
<hr />
<NoteFooter ev={props.ev} reposts={reposts} zaps={zaps} positive={reactions.positive} />
</>
);
}
return (
<div className={classNames("long-form-note flex flex-col g16 p break-words")}>
<ProfilePreview
pubkey={props.ev.pubkey}
actions={
<>
<NoteTime from={props.ev.created_at * 1000} />
</>
}
options={{
about: false,
}}
/>
<h1>{title}</h1>
<small>{summary}</small>
{image && <div className="header-image" style={{ "--img": `url(${proxy(image)})` } as CSSProperties} />}
{props.isPreview ? previewText() : fullText()}
</div>
);
}

View File

@ -0,0 +1,44 @@
.markdown a {
color: var(--highlight);
}
.markdown blockquote {
margin: 0;
color: var(--font-secondary-color);
border-left: 2px solid var(--font-secondary-color);
padding-left: 12px;
}
.markdown hr {
border: 0;
height: 1px;
background-image: var(--gray-gradient);
margin: 20px;
}
.markdown img:not(.custom-emoji),
.markdown video,
.markdown iframe,
.markdown audio {
width: 100%;
display: block;
}
.markdown iframe,
.markdown video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
.markdown ul,
.markdown ol {
padding-inline-start: 20px;
}
.markdown ul {
list-style: circle;
}
.markdown ol {
list-style: decimal;
}

View File

@ -0,0 +1,136 @@
import "./Markdown.css";
import { ReactNode, forwardRef, useMemo } from "react";
import { transformText } from "@snort/system";
import { marked, Token } from "marked";
import { Link } from "react-router-dom";
import markedFootnote, { Footnotes, Footnote, FootnoteRef } from "marked-footnote";
import { ProxyImg } from "@/Components/ProxyImg";
import NostrLink from "@/Components/Embed/NostrLink";
interface MarkdownProps {
content: string;
tags?: Array<Array<string>>;
}
function renderToken(t: Token | Footnotes | Footnote | FootnoteRef, tags: Array<Array<string>>): ReactNode {
try {
switch (t.type) {
case "paragraph": {
return <p>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</p>;
}
case "image": {
return <ProxyImg src={t.href} />;
}
case "heading": {
switch (t.depth) {
case 1:
return <h1>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h1>;
case 2:
return <h2>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h2>;
case 3:
return <h3>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h3>;
case 4:
return <h4>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h4>;
case 5:
return <h5>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h5>;
case 6:
return <h6>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</h6>;
}
throw new Error("Invalid heading");
}
case "codespan": {
return <code>{t.raw}</code>;
}
case "code": {
return <pre>{t.raw}</pre>;
}
case "br": {
return <br />;
}
case "hr": {
return <hr />;
}
case "blockquote": {
return <blockquote>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</blockquote>;
}
case "link": {
return (
<Link to={t.href as string} className="ext" target="_blank">
{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}
</Link>
);
}
case "list": {
if (t.ordered) {
return <ol>{(t.items as Token[]).map(a => renderToken(a, tags))}</ol>;
} else {
return <ul>{(t.items as Token[]).map(a => renderToken(a, tags))}</ul>;
}
}
case "list_item": {
return <li>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</li>;
}
case "em": {
return <em>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</em>;
}
case "del": {
return <s>{t.tokens ? t.tokens.map(a => renderToken(a, tags)) : t.raw}</s>;
}
case "footnoteRef": {
return (
<sup>
<Link to={`#fn-${t.label}`} className="super">
[{t.label}]
</Link>
</sup>
);
}
case "footnotes":
case "footnote": {
return;
}
default: {
if ("tokens" in t) {
return (t.tokens as Array<Token>).map(a => renderToken(a, tags));
}
return transformText(t.raw, tags).map(v => {
switch (v.type) {
case "link": {
if (v.content.startsWith("nostr:")) {
return <NostrLink link={v.content} />;
} else {
return v.content;
}
}
case "mention": {
return <NostrLink link={v.content} />;
}
default: {
return v.content;
}
}
});
}
}
} catch (e) {
console.error(e);
}
}
const Markdown = forwardRef<HTMLDivElement, MarkdownProps>((props: MarkdownProps, ref) => {
const parsed = useMemo(() => {
return marked.use(markedFootnote()).lexer(props.content);
}, [props.content, props.tags]);
return (
<div className="markdown" ref={ref}>
{parsed.filter(a => a.type !== "footnote" && a.type !== "footnotes").map(a => renderToken(a, props.tags ?? []))}
</div>
);
});
Markdown.displayName = "Markdown";
export { Markdown };

View File

@ -0,0 +1,51 @@
import { FormattedMessage } from "react-intl";
import { NostrEvent, NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import { findTag } from "@/Utils";
import PageSpinner from "@/Components/PageSpinner";
import Reveal from "@/Components/Event/Reveal";
import { MediaElement } from "@/Components/Embed/MediaElement";
export default function NostrFileHeader({ link }: { link: NostrLink }) {
const ev = useEventFeed(link);
if (!ev.data) return <PageSpinner />;
return <NostrFileElement ev={ev.data} />;
}
export function NostrFileElement({ ev }: { ev: NostrEvent }) {
// assume image or embed which can be rendered by the hypertext kind
// todo: make use of hash
// todo: use magnet or other links if present
const u = findTag(ev, "url");
const x = findTag(ev, "x");
const m = findTag(ev, "m");
const blurHash = findTag(ev, "blurhash");
const magnet = findTag(ev, "magnet");
if (u && m) {
return (
<Reveal
message={
<FormattedMessage defaultMessage="Click to load content from {link}" id="lsNFM1" values={{ link: u }} />
}>
<MediaElement
mime={m}
url={u}
meta={{
sha256: x,
magnet: magnet,
blurHash: blurHash,
}}
/>
</Reveal>
);
} else {
return (
<b className="error">
<FormattedMessage defaultMessage="Unknown file header: {name}" id="PamNxw" values={{ name: ev.content }} />
</b>
);
}
}

View File

@ -0,0 +1,176 @@
.note > .header .reply {
font-size: 13px;
color: var(--font-secondary-color);
}
.note > .header .reply a {
color: var(--highlight);
}
.note > .header .reply a:hover {
text-decoration-color: var(--highlight);
}
.note .header .info {
font-size: var(--font-size);
margin-left: 4px;
white-space: nowrap;
color: var(--font-secondary-color);
display: flex;
align-items: center;
gap: 8px;
}
.note .header .info .saved {
margin-right: 12px;
font-weight: 600;
font-size: 10px;
line-height: 12px;
letter-spacing: 0.11em;
text-transform: uppercase;
display: flex;
align-items: center;
}
.note .header .info .saved svg {
margin-right: 8px;
}
.note .header .pinned {
font-size: var(--font-size-small);
color: var(--font-secondary-color);
font-weight: 500;
line-height: 22px;
display: flex;
flex-direction: row;
align-items: center;
}
.note .header .pinned svg {
margin-right: 8px;
}
.note-quote {
border: 1px solid var(--gray-superdark);
border-radius: 12px;
padding: 8px 16px 16px 16px;
margin-top: 16px;
}
.note .footer .footer-reactions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-left: auto;
gap: 48px;
}
@media (min-width: 720px) {
.note .footer .footer-reactions {
margin-left: 0;
}
}
.note > .header img:hover,
.note > .header .name > .reply:hover {
cursor: pointer;
}
.note > .note-creator {
margin-top: 12px;
margin-left: 56px;
}
.note .poll-body {
padding: 5px;
user-select: none;
}
.note .poll-body > div {
border: 1px solid var(--font-secondary-color);
border-radius: 5px;
margin-bottom: 3px;
position: relative;
overflow: hidden;
}
.note .poll-body > div > div {
padding: 5px 10px;
z-index: 2;
}
.note .poll-body > div:hover {
cursor: pointer;
border: 1px solid var(--highlight);
}
.note .poll-body > div > .progress {
background-color: var(--gray);
height: stretch;
height: -webkit-fill-available;
height: -moz-available;
position: absolute;
z-index: 1;
}
.reaction-pill {
display: flex;
min-width: 1rem;
align-items: center;
justify-content: center;
user-select: none;
font-feature-settings: "tnum";
gap: 5px;
}
.reaction-pill:not(.reacted):not(:hover) {
color: var(--font-secondary-color);
}
.trash-icon {
color: var(--error);
}
.note-expand .body {
max-height: 300px;
overflow-y: hidden;
}
.hidden-note .header {
display: flex;
align-items: center;
}
.card.note.hidden-note {
min-height: unset;
}
.expand-note {
padding: 0 0 16px 0;
font-weight: 400;
color: var(--highlight);
cursor: pointer;
}
.note.active {
border-left: 1px solid var(--highlight);
margin-left: -1px;
}
.note .reactions-link {
color: var(--font-secondary-color);
font-weight: 400;
font-size: 14px;
line-height: 24px;
margin-top: 4px;
font-feature-settings: "tnum";
}
.note .reactions-link:hover {
text-decoration: underline;
}
.note .body > .text > a {
color: var(--highlight);
}

View File

@ -0,0 +1,90 @@
import "./Note.css";
import { ReactNode } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system";
import { NostrFileElement } from "@/Components/Event/NostrFileHeader";
import ZapstrEmbed from "@/Components/Embed/ZapstrEmbed";
import PubkeyList from "@/Components/Embed/PubkeyList";
import { LiveEvent } from "@/Components/LiveStream/LiveEvent";
import { ZapGoal } from "@/Components/Event/ZapGoal";
import NoteReaction from "@/Components/Event/NoteReaction";
import ProfilePreview from "@/Components/User/ProfilePreview";
import { NoteInner } from "./NoteInner";
import { LongFormText } from "./LongFormText";
import ErrorBoundary from "@/Components/ErrorBoundary";
export interface NoteProps {
data: TaggedNostrEvent;
className?: string;
related: readonly TaggedNostrEvent[];
highlight?: boolean;
ignoreModeration?: boolean;
onClick?: (e: TaggedNostrEvent) => void;
depth?: number;
searchedValue?: string;
threadChains?: Map<string, Array<NostrEvent>>;
context?: ReactNode;
options?: {
isRoot?: boolean;
showHeader?: boolean;
showContextMenu?: boolean;
showProfileCard?: boolean;
showTime?: boolean;
showPinned?: boolean;
showBookmarked?: boolean;
showFooter?: boolean;
showReactionsLink?: boolean;
showMedia?: boolean;
canUnpin?: boolean;
canUnbookmark?: boolean;
canClick?: boolean;
showMediaSpotlight?: boolean;
longFormPreview?: boolean;
truncate?: boolean;
};
waitUntilInView?: boolean;
}
export default function Note(props: NoteProps) {
const { data: ev, className } = props;
let content;
switch (ev.kind) {
case EventKind.Repost:
content = <NoteReaction data={ev} key={ev.id} root={undefined} depth={(props.depth ?? 0) + 1} />;
break;
case EventKind.FileHeader:
content = <NostrFileElement ev={ev} />;
break;
case EventKind.ZapstrTrack:
content = <ZapstrEmbed ev={ev} />;
break;
case EventKind.FollowSet:
case EventKind.ContactList:
content = <PubkeyList ev={ev} className={className} />;
break;
case EventKind.LiveEvent:
content = <LiveEvent ev={ev} />;
break;
case EventKind.SetMetadata:
content = <ProfilePreview actions={<></>} pubkey={ev.pubkey} />;
break;
case 9041: // Assuming 9041 is a valid EventKind
content = <ZapGoal ev={ev} />;
break;
case EventKind.LongFormTextNote:
content = (
<LongFormText
ev={ev}
related={props.related}
isPreview={props.options?.longFormPreview ?? false}
onClick={() => props.onClick?.(ev)}
truncate={props.options?.truncate}
/>
);
break;
default:
content = <NoteInner {...props} />;
}
return <ErrorBoundary>{content}</ErrorBoundary>;
}

View File

@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { Menu, MenuItem } from "@szhsin/react-menu";
import Icon from "@/Components/Icons/Icon";
import { setPinned, setBookmarked } from "@/Utils/Login";
import messages from "@/Components/messages";
import useLogin from "@/Hooks/useLogin";
import useModeration from "@/Hooks/useModeration";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { ReBroadcaster } from "../ReBroadcaster";
import SnortApi from "@/External/SnortApi";
import { SubscriptionType, getCurrentSubscription } from "@/Utils/Subscription";
export interface NoteTranslation {
text: string;
fromLanguage: string;
confidence: number;
}
interface NosteContextMenuProps {
ev: TaggedNostrEvent;
setShowReactions(b: boolean): void;
react(content: string): Promise<void>;
onTranslated?: (t: NoteTranslation) => void;
}
export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) {
const { formatMessage } = useIntl();
const login = useLogin();
const { mute, block } = useModeration();
const { publisher, system } = useEventPublisher();
const [showBroadcast, setShowBroadcast] = useState(false);
const lang = window.navigator.language;
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
type: "language",
});
const isMine = ev.pubkey === login.publicKey;
async function deleteEvent() {
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) {
const evDelete = await publisher.delete(ev.id);
system.BroadcastEvent(evDelete);
}
}
async function share() {
const link = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
const url = `${window.location.protocol}//${window.location.host}/${link}`;
if ("share" in window.navigator) {
await window.navigator.share({
title: "Snort",
url: url,
});
} else {
await navigator.clipboard.writeText(url);
}
}
async function translate() {
const api = new SnortApi();
const targetLang = lang.split("-")[0].toUpperCase();
const result = await api.translate({
text: [ev.content],
target_lang: targetLang,
});
if ("translations" in result) {
if (
typeof props.onTranslated === "function" &&
result.translations.length > 0 &&
targetLang != result.translations[0].detected_source_language
) {
props.onTranslated({
text: result.translations[0].text,
fromLanguage: langNames.of(result.translations[0].detected_source_language),
confidence: 1,
} as NoteTranslation);
}
}
}
useEffect(() => {
const sub = getCurrentSubscription(login.subscriptions);
if (sub?.type === SubscriptionType.Premium && (login.appData.item.preferences.autoTranslate ?? true)) {
translate();
}
}, []);
async function copyId() {
const link = NostrLink.fromEvent(ev).encode(CONFIG.eventLinkPrefix);
await navigator.clipboard.writeText(link);
}
async function pin(id: HexKey) {
if (publisher) {
const es = [...login.pinned.item, id];
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
async function bookmark(id: string) {
if (publisher) {
const es = [...login.bookmarked.item, id];
const ev = await publisher.bookmarks(
es.map(a => new NostrLink(NostrPrefix.Note, a)),
"bookmark",
);
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
async function copyEvent() {
await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " "));
}
const handleReBroadcastButtonClick = () => {
setShowBroadcast(true);
};
function menuItems() {
return (
<>
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => props.setShowReactions(true)}>
<Icon name="heart" />
<FormattedMessage {...messages.Reactions} />
</MenuItem>
<MenuItem onClick={() => share()}>
<Icon name="share" />
<FormattedMessage {...messages.Share} />
</MenuItem>
{!login.pinned.item.includes(ev.id) && !login.readonly && (
<MenuItem onClick={() => pin(ev.id)}>
<Icon name="pin" />
<FormattedMessage {...messages.Pin} />
</MenuItem>
)}
{!login.bookmarked.item.includes(ev.id) && !login.readonly && (
<MenuItem onClick={() => bookmark(ev.id)}>
<Icon name="bookmark" />
<FormattedMessage {...messages.Bookmark} />
</MenuItem>
)}
<MenuItem onClick={() => copyId()}>
<Icon name="copy" />
<FormattedMessage {...messages.CopyID} />
</MenuItem>
{!login.readonly && (
<MenuItem onClick={() => mute(ev.pubkey)}>
<Icon name="mute" />
<FormattedMessage {...messages.Mute} />
</MenuItem>
)}
{login.appData.item.preferences.enableReactions && !login.readonly && (
<MenuItem onClick={() => props.react("-")}>
<Icon name="dislike" />
<FormattedMessage {...messages.DislikeAction} />
</MenuItem>
)}
<MenuItem onClick={handleReBroadcastButtonClick}>
<Icon name="relay" />
<FormattedMessage defaultMessage="Broadcast Event" id="Gxcr08" />
</MenuItem>
{ev.pubkey !== login.publicKey && !login.readonly && (
<MenuItem onClick={() => block(ev.pubkey)}>
<Icon name="block" />
<FormattedMessage {...messages.Block} />
</MenuItem>
)}
<MenuItem onClick={() => translate()}>
<Icon name="translate" />
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
</MenuItem>
<MenuItem onClick={() => copyEvent()}>
<Icon name="json" />
<FormattedMessage {...messages.CopyJSON} />
</MenuItem>
{isMine && !login.readonly && (
<MenuItem onClick={() => deleteEvent()}>
<Icon name="trash" className="red" />
<FormattedMessage {...messages.Delete} />
</MenuItem>
)}
</>
);
}
return (
<>
<Menu
menuButton={
<div className="reaction-pill cursor-pointer">
<Icon name="dots" size={15} />
</div>
}
menuClassName="ctx-menu">
{menuItems()}
</Menu>
{showBroadcast && <ReBroadcaster ev={ev} onClose={() => setShowBroadcast(false)} />}
</>
);
}

View File

@ -0,0 +1,329 @@
import React, { forwardRef, useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useLongPress } from "use-long-press";
import { TaggedNostrEvent, ParsedZap, countLeadingZeros, NostrLink } from "@snort/system";
import { normalizeReaction } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import { Menu, MenuItem } from "@szhsin/react-menu";
import classNames from "classnames";
import { formatShort } from "@/Utils/Number";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { delay, findTag, getDisplayName } from "@/Utils";
import SendSats from "@/Components/SendSats/SendSats";
import { ZapsSummary } from "@/Components/Event/Zap";
import { AsyncIcon, AsyncIconProps } from "@/Components/Button/AsyncIcon";
import { useWallet } from "@/Wallet";
import useLogin from "@/Hooks/useLogin";
import { useInteractionCache } from "@/Hooks/useInteractionCache";
import { ZapPoolController } from "@/Utils/ZapPoolController";
import { Zapper, ZapTarget } from "@/Utils/Zapper";
import { useNoteCreator } from "@/State/NoteCreator";
import Icon from "@/Components/Icons/Icon";
import messages from "../messages";
let isZapperBusy = false;
const barrierZapper = async <T,>(then: () => Promise<T>): Promise<T> => {
while (isZapperBusy) {
await delay(100);
}
isZapperBusy = true;
try {
return await then();
} finally {
isZapperBusy = false;
}
};
export interface NoteFooterProps {
reposts: TaggedNostrEvent[];
zaps: ParsedZap[];
positive: TaggedNostrEvent[];
replies?: number;
ev: TaggedNostrEvent;
}
export default function NoteFooter(props: NoteFooterProps) {
const { ev, positive, reposts, zaps } = props;
const { formatMessage } = useIntl();
const {
publicKey,
preferences: prefs,
readonly,
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, readonly: s.readonly }));
const author = useUserProfile(ev.pubkey);
const interactionCache = useInteractionCache(publicKey, ev.id);
const { publisher, system } = useEventPublisher();
const note = useNoteCreator(n => ({ show: n.show, replyTo: n.replyTo, update: n.update, quote: n.quote }));
const [tip, setTip] = useState(false);
const [zapping, setZapping] = useState(false);
const walletState = useWallet();
const wallet = walletState.wallet;
const canFastZap = wallet?.isReady() && !readonly;
const isMine = ev.pubkey === publicKey;
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey);
const longPress = useLongPress(
e => {
e.stopPropagation();
setTip(true);
},
{
captureEvent: true,
},
);
function hasReacted(emoji: string) {
return (
interactionCache.data.reacted ||
positive?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === publicKey)
);
}
function hasReposted() {
return interactionCache.data.reposted || reposts.some(a => a.pubkey === publicKey);
}
async function react(content: string) {
if (!hasReacted(content) && publisher) {
const evLike = await publisher.react(ev, content);
system.BroadcastEvent(evLike);
interactionCache.react();
}
}
async function repost() {
if (!hasReposted() && publisher) {
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) {
const evRepost = await publisher.repost(ev);
system.BroadcastEvent(evRepost);
await interactionCache.repost();
}
}
}
function getZapTarget(): Array<ZapTarget> | undefined {
if (ev.tags.some(v => v[0] === "zap")) {
return Zapper.fromEvent(ev);
}
const authorTarget = author?.lud16 || author?.lud06;
if (authorTarget) {
return [
{
type: "lnurl",
value: authorTarget,
weight: 1,
name: getDisplayName(author, ev.pubkey),
zap: {
pubkey: ev.pubkey,
event: NostrLink.fromEvent(ev),
},
} as ZapTarget,
];
}
}
async function fastZap(e?: React.MouseEvent) {
if (zapping || e?.isPropagationStopped()) return;
const lnurl = getZapTarget();
if (canFastZap && lnurl) {
setZapping(true);
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch (e) {
console.warn("Fast zap failed", e);
if (!(e instanceof Error) || e.message !== "User rejected") {
setTip(true);
}
} finally {
setZapping(false);
}
} else {
setTip(true);
}
}
async function fastZapInner(targets: Array<ZapTarget>, amount: number) {
if (wallet) {
// only allow 1 invoice req/payment at a time to avoid hitting rate limits
await barrierZapper(async () => {
const zapper = new Zapper(system, publisher);
const result = await zapper.send(wallet, targets, amount);
const totalSent = result.reduce((acc, v) => (acc += v.sent), 0);
if (totalSent > 0) {
if (CONFIG.features.zapPool) {
ZapPoolController?.allocate(totalSent);
}
await interactionCache.zap();
}
});
}
}
useEffect(() => {
if (prefs.autoZap && !didZap && !isMine && !zapping) {
const lnurl = getZapTarget();
if (wallet?.isReady() && lnurl) {
setZapping(true);
queueMicrotask(async () => {
try {
await fastZapInner(lnurl, prefs.defaultZapAmount);
} catch {
// ignored
} finally {
setZapping(false);
}
});
}
}
}, [prefs.autoZap, author, zapping]);
function powIcon() {
const pow = findTag(ev, "nonce") ? countLeadingZeros(ev.id) : undefined;
if (pow) {
return (
<AsyncFooterIcon
title={formatMessage({ defaultMessage: "Proof of Work", id: "grQ+mI" })}
iconName="diamond"
value={pow}
/>
);
}
}
function tipButton() {
const targets = getZapTarget();
if (targets) {
return (
<AsyncFooterIcon
className={didZap ? "reacted text-nostr-orange" : "hover:text-nostr-orange"}
{...longPress()}
title={formatMessage({ defaultMessage: "Zap", id: "fBI91o" })}
iconName={canFastZap ? "zapFast" : "zap"}
value={zapTotal}
onClick={e => fastZap(e)}
/>
);
}
return null;
}
function repostIcon() {
if (readonly) return;
return (
<Menu
menuButton={
<AsyncFooterIcon
className={hasReposted() ? "reacted text-nostr-blue" : "hover:text-nostr-blue"}
iconName="repeat"
title={formatMessage({ defaultMessage: "Repost", id: "JeoS4y" })}
value={reposts.length}
/>
}
menuClassName="ctx-menu"
align="start">
<div className="close-menu-container">
{/* This menu item serves as a "close menu" button;
it allows the user to click anywhere nearby the menu to close it. */}
<MenuItem>
<div className="close-menu" />
</MenuItem>
</div>
<MenuItem onClick={() => repost()} disabled={hasReposted()}>
<Icon name="repeat" />
<FormattedMessage defaultMessage="Repost" id="JeoS4y" />
</MenuItem>
<MenuItem
onClick={() =>
note.update(n => {
n.reset();
n.quote = ev;
n.show = true;
})
}>
<Icon name="edit" />
<FormattedMessage defaultMessage="Quote Repost" id="C7642/" />
</MenuItem>
</Menu>
);
}
function reactionIcon() {
if (!prefs.enableReactions) {
return null;
}
const reacted = hasReacted("+");
return (
<AsyncFooterIcon
className={reacted ? "reacted text-nostr-red" : "hover:text-nostr-red"}
iconName={reacted ? "heart-solid" : "heart"}
title={formatMessage({ defaultMessage: "Like", id: "qtWLmt" })}
value={positive.length}
onClick={async () => {
if (readonly) return;
await react(prefs.reactionEmoji);
}}
/>
);
}
function replyIcon() {
if (readonly) return;
return (
<AsyncFooterIcon
className={note.show ? "reacted text-nostr-purple" : "hover:text-nostr-purple"}
iconName="reply"
title={formatMessage({ defaultMessage: "Reply", id: "9HU8vw" })}
value={props.replies ?? 0}
onClick={async () => handleReplyButtonClick()}
/>
);
}
const handleReplyButtonClick = () => {
note.update(v => {
if (v.replyTo?.id !== ev.id) {
v.reset();
}
v.show = true;
v.replyTo = ev;
});
};
return (
<>
<div className="footer">
<div className="footer-reactions">
{replyIcon()}
{repostIcon()}
{reactionIcon()}
{tipButton()}
{powIcon()}
</div>
<SendSats targets={getZapTarget()} onClose={() => setTip(false)} show={tip} note={ev.id} allocatePool={true} />
</div>
<ZapsSummary zaps={zaps} />
</>
);
}
const AsyncFooterIcon = forwardRef((props: AsyncIconProps & { value: number }, ref) => {
const mergedProps = {
...props,
iconSize: 18,
className: classNames("transition duration-200 ease-in-out reaction-pill cursor-pointer", props.className),
};
return (
<AsyncIcon ref={ref} {...mergedProps}>
{props.value > 0 && <div className="reaction-pill-number">{formatShort(props.value)}</div>}
</AsyncIcon>
);
});
AsyncFooterIcon.displayName = "AsyncFooterIcon";

View File

@ -0,0 +1,20 @@
import "./Note.css";
import ProfileImage from "@/Components/User/ProfileImage";
interface NoteGhostProps {
className?: string;
children: React.ReactNode;
}
export default function NoteGhost(props: NoteGhostProps) {
const className = `note card ${props.className ?? ""}`;
return (
<div className={className}>
<div className="header">
<ProfileImage pubkey="" />
</div>
<div className="body">{props.children}</div>
<div className="footer"></div>
</div>
);
}

View File

@ -0,0 +1,415 @@
import { Link, useNavigate } from "react-router-dom";
import React, { ReactNode, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { FormattedMessage, useIntl } from "react-intl";
import classNames from "classnames";
import { EventExt, EventKind, HexKey, NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { useEventReactions } from "@snort/system-react";
import { findTag, hexToBech32 } from "@/Utils";
import useModeration from "@/Hooks/useModeration";
import useLogin from "@/Hooks/useLogin";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { NoteContextMenu, NoteTranslation } from "./NoteContextMenu";
import { UserCache } from "@/Cache";
import messages from "../messages";
import { setBookmarked, setPinned } from "@/Utils/Login";
import Text from "../Text/Text";
import Reveal from "./Reveal";
import Poll from "./Poll";
import ProfileImage from "../User/ProfileImage";
import Icon from "@/Components/Icons/Icon";
import NoteTime from "./NoteTime";
import NoteFooter from "./NoteFooter";
import Reactions from "./Reactions";
import HiddenNote from "./HiddenNote";
import { NoteProps } from "./Note";
import { chainKey } from "@/Hooks/useThreadContext";
import { ProfileLink } from "@/Components/User/ProfileLink";
import DisplayName from "@/Components/User/DisplayName";
const TEXT_TRUNCATE_LENGTH = 400;
export function NoteInner(props: NoteProps) {
const { data: ev, related, highlight, options: opt, ignoreModeration = false, className, waitUntilInView } = props;
const baseClassName = classNames("note min-h-[110px] flex flex-col gap-4 card", className);
const navigate = useNavigate();
const [showReactions, setShowReactions] = useState(false);
const { isEventMuted } = useModeration();
const { ref, inView } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const { reactions, reposts, deletions, zaps } = useEventReactions(NostrLink.fromEvent(ev), related);
const login = useLogin();
const { pinned, bookmarked } = useLogin();
const { publisher, system } = useEventPublisher();
const [translated, setTranslated] = useState<NoteTranslation>();
const [showTranslation, setShowTranslation] = useState(true);
const { formatMessage } = useIntl();
const [showMore, setShowMore] = useState(false);
const totalReactions = reactions.positive.length + reactions.negative.length + reposts.length + zaps.length;
const options = {
showHeader: true,
showTime: true,
showFooter: true,
canUnpin: false,
canUnbookmark: false,
showContextMenu: true,
...opt,
};
async function unpin(id: HexKey) {
if (options.canUnpin && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
const es = pinned.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setPinned(login, es, ev.created_at * 1000);
}
}
}
async function unbookmark(id: HexKey) {
if (options.canUnbookmark && publisher) {
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
const es = bookmarked.item.filter(e => e !== id);
const ev = await publisher.pinned(es.map(a => new NostrLink(NostrPrefix.Note, a)));
system.BroadcastEvent(ev);
setBookmarked(login, es, ev.created_at * 1000);
}
}
}
const ToggleShowMore = () => (
<a
className="highlight"
onClick={e => {
e.preventDefault();
e.stopPropagation();
setShowMore(!showMore);
}}>
{showMore ? (
<FormattedMessage defaultMessage="Show less" id="qyJtWy" />
) : (
<FormattedMessage defaultMessage="Show more" id="aWpBzj" />
)}
</a>
);
const innerContent = useMemo(() => {
const body = translated && showTranslation ? translated.text : ev?.content ?? "";
const id = translated && showTranslation ? `${ev.id}-translated` : ev.id;
const shouldTruncate = opt?.truncate && body.length > TEXT_TRUNCATE_LENGTH;
return (
<>
{shouldTruncate && showMore && <ToggleShowMore />}
<Text
id={id}
highlighText={props.searchedValue}
content={body}
tags={ev.tags}
creator={ev.pubkey}
depth={props.depth}
disableMedia={!(options.showMedia ?? true)}
disableMediaSpotlight={!(props.options?.showMediaSpotlight ?? true)}
truncate={shouldTruncate && !showMore ? TEXT_TRUNCATE_LENGTH : undefined}
/>
{shouldTruncate && !showMore && <ToggleShowMore />}
</>
);
}, [
showMore,
ev,
translated,
showTranslation,
props.searchedValue,
props.depth,
options.showMedia,
props.options?.showMediaSpotlight,
opt?.truncate,
TEXT_TRUNCATE_LENGTH,
]);
const transformBody = () => {
if (deletions?.length > 0) {
return (
<b className="error">
<FormattedMessage {...messages.Deleted} />
</b>
);
}
if (!login.appData.item.showContentWarningPosts) {
const contentWarning = ev.tags.find(a => a[0] === "content-warning");
if (contentWarning) {
return (
<Reveal
message={
<>
<FormattedMessage
defaultMessage="The author has marked this note as a <i>sensitive topic</i>"
id="StKzTE"
values={{
i: c => <i>{c}</i>,
}}
/>
{contentWarning[1] && (
<>
&nbsp;
<FormattedMessage
defaultMessage="Reason: <i>{reason}</i>"
id="6OSOXl"
values={{
i: c => <i>{c}</i>,
reason: contentWarning[1],
}}
/>
</>
)}
. <FormattedMessage defaultMessage="Click here to load anyway" id="IoQq+a" />.{" "}
<Link to="/settings/moderation">
<i>
<FormattedMessage defaultMessage="Settings" id="D3idYv" />
</i>
</Link>
</>
}>
{innerContent}
</Reveal>
);
}
}
return innerContent;
};
function goToEvent(e: React.MouseEvent, eTarget: TaggedNostrEvent) {
if (opt?.canClick === false) {
return;
}
let target = e.target as HTMLElement | null;
while (target) {
if (
target.tagName === "A" ||
target.tagName === "BUTTON" ||
target.classList.contains("reaction-pill") ||
target.classList.contains("szh-menu-container")
) {
return; // is there a better way to do this?
}
target = target.parentElement;
}
e.stopPropagation();
if (props.onClick) {
props.onClick(eTarget);
return;
}
const link = NostrLink.fromEvent(eTarget);
// detect cmd key and open in new tab
if (e.metaKey) {
window.open(`/${link.encode(CONFIG.eventLinkPrefix)}`, "_blank");
} else {
navigate(`/${link.encode(CONFIG.eventLinkPrefix)}`, {
state: eTarget,
});
}
}
function replyTag() {
const thread = EventExt.extractThread(ev);
if (thread === undefined) {
return undefined;
}
const maxMentions = 2;
const replyTo = thread?.replyTo ?? thread?.root;
const replyLink = replyTo
? NostrLink.fromTag(
[replyTo.key, replyTo.value ?? "", replyTo.relay ?? "", replyTo.marker ?? ""].filter(a => a.length > 0),
)
: undefined;
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
for (const pk of thread?.pubKeys ?? []) {
const u = UserCache.getFromCache(pk);
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
const shortNpub = npub.substring(0, 12);
mentions.push({
pk,
name: u?.name ?? shortNpub,
link: (
<ProfileLink pubkey={pk} user={u}>
<DisplayName pubkey={pk} user={u} />{" "}
</ProfileLink>
),
});
}
mentions.sort(a => (a.name.startsWith(NostrPrefix.PublicKey) ? 1 : -1));
const othersLength = mentions.length - maxMentions;
const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => {
return (
<React.Fragment key={m.pk}>
{idx > 0 && ", "}
{m.link}
</React.Fragment>
);
};
const pubMentions =
mentions.length > maxMentions ? mentions?.slice(0, maxMentions).map(renderMention) : mentions?.map(renderMention);
const others = mentions.length > maxMentions ? formatMessage(messages.Others, { n: othersLength }) : "";
const link = replyLink?.encode(CONFIG.eventLinkPrefix);
return (
<div className="reply">
re:&nbsp;
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions} {others}
</>
) : (
replyLink && <Link to={`/${link}`}>{link?.substring(0, 12)}</Link>
)}
</div>
);
}
const canRenderAsTextNote = [EventKind.TextNote, EventKind.Polls];
if (!canRenderAsTextNote.includes(ev.kind)) {
const alt = findTag(ev, "alt");
if (alt) {
return (
<div className="note-quote">
<Text id={ev.id} content={alt} tags={[]} creator={ev.pubkey} />
</div>
);
} else {
return (
<>
<h4>
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.kind }} />
</h4>
<pre>{JSON.stringify(ev, undefined, " ")}</pre>
</>
);
}
}
function translation() {
if (translated && translated.confidence > 0.5) {
return (
<>
<span
className="text-xs font-semibold text-gray-light select-none"
onClick={e => {
e.stopPropagation();
setShowTranslation(s => !s);
}}>
<FormattedMessage {...messages.TranslatedFrom} values={{ lang: translated.fromLanguage }} />
</span>
</>
);
} else if (translated) {
return (
<p className="text-xs font-semibold text-gray-light">
<FormattedMessage {...messages.TranslationFailed} />
</p>
);
}
}
function pollOptions() {
if (ev.kind !== EventKind.Polls) return;
return <Poll ev={ev} zaps={zaps} />;
}
function content() {
if (waitUntilInView && !inView) return undefined;
return (
<>
{options.showHeader && (
<div className="header flex">
<ProfileImage
pubkey={ev.pubkey}
subHeader={replyTag() ?? undefined}
link={opt?.canClick === undefined ? undefined : ""}
showProfileCard={options.showProfileCard ?? true}
showBadges={true}
/>
<div className="info">
{props.context}
{(options.showTime || options.showBookmarked) && (
<>
{options.showBookmarked && (
<div
className={`saved ${options.canUnbookmark ? "pointer" : ""}`}
onClick={() => unbookmark(ev.id)}>
<Icon name="bookmark" /> <FormattedMessage {...messages.Bookmarked} />
</div>
)}
{!options.showBookmarked && <NoteTime from={ev.created_at * 1000} />}
</>
)}
{options.showPinned && (
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.id)}>
<Icon name="pin" /> <FormattedMessage {...messages.Pinned} />
</div>
)}
{options.showContextMenu && (
<NoteContextMenu
ev={ev}
react={async () => {}}
onTranslated={t => setTranslated(t)}
setShowReactions={setShowReactions}
/>
)}
</div>
</div>
)}
<div className="body" onClick={e => goToEvent(e, ev, true)}>
{transformBody()}
{translation()}
{pollOptions()}
{options.showReactionsLink && (
<span className="reactions-link cursor-pointer" onClick={() => setShowReactions(true)}>
<FormattedMessage {...messages.ReactionsLink} values={{ n: totalReactions }} />
</span>
)}
</div>
{options.showFooter && (
<NoteFooter
ev={ev}
positive={reactions.positive}
reposts={reposts}
zaps={zaps}
replies={props.threadChains?.get(chainKey(ev))?.length}
/>
)}
<Reactions
show={showReactions}
setShow={setShowReactions}
positive={reactions.positive}
negative={reactions.negative}
reposts={reposts}
zaps={zaps}
/>
</>
);
}
const note = (
<div
className={classNames(baseClassName, {
active: highlight,
"hover:bg-nearly-bg-color cursor-pointer": !opt?.isRoot,
})}
onClick={e => goToEvent(e, ev)}
ref={ref}>
{content()}
</div>
);
return !ignoreModeration && isEventMuted(ev) ? <HiddenNote>{note}</HiddenNote> : note;
}

View File

@ -0,0 +1,27 @@
import { NostrLink } from "@snort/system";
import { useEventFeed } from "@snort/system-react";
import Note from "@/Components/Event/Note";
import PageSpinner from "@/Components/PageSpinner";
export default function NoteQuote({ link, depth }: { link: NostrLink; depth?: number }) {
const ev = useEventFeed(link);
if (!ev.data)
return (
<div className="note-quote flex items-center justify-center h-[110px]">
<PageSpinner />
</div>
);
return (
<Note
data={ev.data}
related={[]}
className="note-quote"
depth={(depth ?? 0) + 1}
options={{
showFooter: false,
truncate: true,
}}
/>
);
}

View File

@ -0,0 +1,14 @@
.reaction {
display: flex;
flex-direction: column;
gap: 8px;
}
.reaction > div:nth-child(1) {
font-size: 16px;
font-weight: 600;
}
.reaction > div:nth-child(1) svg {
opacity: 0.5;
}

View File

@ -0,0 +1,99 @@
import "./NoteReaction.css";
import { Link } from "react-router-dom";
import { useMemo } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix, EventExt } from "@snort/system";
import Note from "@/Components/Event/Note";
import { eventLink, hexToBech32, getDisplayName } from "@/Utils";
import useModeration from "@/Hooks/useModeration";
import { FormattedMessage } from "react-intl";
import Icon from "@/Components/Icons/Icon";
import { useUserProfile } from "@snort/system-react";
import { useInView } from "react-intersection-observer";
export interface NoteReactionProps {
data: TaggedNostrEvent;
root?: TaggedNostrEvent;
depth?: number;
}
export default function NoteReaction(props: NoteReactionProps) {
const { data: ev } = props;
const { isMuted } = useModeration();
const { inView, ref } = useInView({ triggerOnce: true, rootMargin: "2000px" });
const profile = useUserProfile(inView ? ev.pubkey : "");
const root = useMemo(() => extractRoot(), [ev, props.root, inView]);
const refEvent = useMemo(() => {
if (ev) {
const eTags = ev.tags.filter(a => a[0] === "e");
if (eTags.length > 0) {
return eTags[0];
}
}
return null;
}, [ev]);
/**
* Some clients embed the reposted note in the content
*/
function extractRoot() {
if (!inView) return null;
if (ev?.kind === EventKind.Repost && ev.content.length > 0 && ev.content !== "#[0]") {
try {
const r: NostrEvent = JSON.parse(ev.content);
EventExt.fixupEvent(r);
if (!EventExt.verify(r)) {
console.debug("Event in repost is invalid");
return undefined;
}
return r as TaggedNostrEvent;
} catch (e) {
console.error("Could not load reposted content", e);
}
}
return props.root;
}
if (
ev.kind !== EventKind.Reaction &&
ev.kind !== EventKind.Repost &&
(ev.kind !== EventKind.TextNote ||
ev.tags.every((a, i) => a[1] !== refEvent?.[1] || a[3] !== "mention" || ev.content !== `#[${i}]`))
) {
return null;
}
if (!inView) {
return <div className="card reaction" ref={ref}></div>;
}
const isOpMuted = root && isMuted(root.pubkey);
const shouldNotBeRendered = isOpMuted || root?.kind !== EventKind.TextNote;
const opt = {
showHeader: ev?.kind === EventKind.Repost || ev?.kind === EventKind.TextNote,
showFooter: false,
truncate: true,
};
return shouldNotBeRendered ? null : (
<div className="card reaction">
<div className="flex g4">
<Icon name="repeat" size={18} />
<FormattedMessage
defaultMessage="{name} reposted"
id="+xliwN"
values={{
name: getDisplayName(profile, ev.pubkey),
}}
/>
</div>
{root ? <Note data={root} options={opt} related={[]} depth={props.depth} /> : null}
{!root && refEvent ? (
<p>
<Link to={eventLink(refEvent[1] ?? "", refEvent[2])}>
#{hexToBech32(NostrPrefix.Event, refEvent[1]).substring(0, 12)}
</Link>
</p>
) : null}
</div>
);
}

View File

@ -0,0 +1,74 @@
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
export interface NoteTimeProps {
from: number;
fallback?: string;
}
const secondsInAMinute = 60;
const secondsInAnHour = secondsInAMinute * 60;
const secondsInADay = secondsInAnHour * 24;
export default function NoteTime(props: NoteTimeProps) {
const { from, fallback } = props;
const [time, setTime] = useState<string | JSX.Element>(calcTime());
const absoluteTime = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "long",
}).format(from),
[from],
);
const isoDate = new Date(from).toISOString();
function calcTime() {
const fromDate = new Date(from);
const currentTime = new Date();
const timeDifference = Math.floor((currentTime.getTime() - fromDate.getTime()) / 1000);
if (timeDifference < secondsInAMinute) {
return <FormattedMessage defaultMessage="now" id="kaaf1E" />;
} else if (timeDifference < secondsInAnHour) {
return `${Math.floor(timeDifference / secondsInAMinute)}m`;
} else if (timeDifference < secondsInADay) {
return `${Math.floor(timeDifference / secondsInAnHour)}h`;
} else {
if (fromDate.getFullYear() === currentTime.getFullYear()) {
return fromDate.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
} else {
return fromDate.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
}
}
useEffect(() => {
setTime(calcTime());
const t = setInterval(() => {
setTime(s => {
const newTime = calcTime();
if (newTime !== s) {
return newTime;
}
return s;
});
}, 60_000); // update every minute
return () => clearInterval(t);
}, [from]);
return (
<time dateTime={isoDate} title={absoluteTime}>
{time || fallback}
</time>
);
}

View File

@ -0,0 +1,178 @@
import { TaggedNostrEvent, ParsedZap, NostrLink } from "@snort/system";
import { LNURL } from "@snort/shared";
import { useState } from "react";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react";
import useEventPublisher from "@/Hooks/useEventPublisher";
import { useWallet } from "@/Wallet";
import { unwrap } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import Spinner from "@/Components/Icons/Spinner";
import SendSats from "@/Components/SendSats/SendSats";
import useLogin from "@/Hooks/useLogin";
interface PollProps {
ev: TaggedNostrEvent;
zaps: Array<ParsedZap>;
}
type PollTally = "zaps" | "pubkeys";
export default function Poll(props: PollProps) {
const { formatMessage } = useIntl();
const { publisher } = useEventPublisher();
const { wallet } = useWallet();
const {
preferences: prefs,
publicKey: myPubKey,
relays,
} = useLogin(s => ({ preferences: s.appData.item.preferences, publicKey: s.publicKey, relays: s.relays }));
const pollerProfile = useUserProfile(props.ev.pubkey);
const [tallyBy, setTallyBy] = useState<PollTally>("pubkeys");
const [error, setError] = useState("");
const [invoice, setInvoice] = useState("");
const [voting, setVoting] = useState<number>();
const didVote = props.zaps.some(a => a.sender === myPubKey);
const isMyPoll = props.ev.pubkey === myPubKey;
const showResults = didVote || isMyPoll;
const options = props.ev.tags
.filter(a => a[0] === "poll_option")
.sort((a, b) => (Number(a[1]) > Number(b[1]) ? 1 : -1));
async function zapVote(ev: React.MouseEvent, opt: number) {
ev.stopPropagation();
if (voting || !publisher) return;
const amount = prefs.defaultZapAmount;
try {
if (amount <= 0) {
throw new Error(
formatMessage(
{
defaultMessage: "Can't vote with {amount} sats, please set a different default zap amount",
id: "NepkXH",
},
{
amount,
},
),
);
}
setVoting(opt);
const r = Object.keys(relays.item);
const zap = await publisher.zap(amount * 1000, props.ev.pubkey, r, NostrLink.fromEvent(props.ev), undefined, eb =>
eb.tag(["poll_option", opt.toString()]),
);
const lnurl = props.ev.tags.find(a => a[0] === "zap")?.[1] || pollerProfile?.lud16 || pollerProfile?.lud06;
if (!lnurl) return;
const svc = new LNURL(lnurl);
await svc.load();
if (!svc.canZap) {
throw new Error(
formatMessage({
defaultMessage: "Can't vote because LNURL service does not support zaps",
id: "fOksnD",
}),
);
}
const invoice = await svc.getInvoice(amount, undefined, zap);
if (wallet?.isReady()) {
await wallet?.payInvoice(unwrap(invoice.pr));
} else {
setInvoice(unwrap(invoice.pr));
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError(
formatMessage({
defaultMessage: "Failed to send vote",
id: "g985Wp",
}),
);
}
} finally {
setVoting(undefined);
}
}
const totalVotes = (() => {
switch (tallyBy) {
case "zaps":
return props.zaps.filter(a => a.pollOption !== undefined).reduce((acc, v) => (acc += v.amount), 0);
case "pubkeys":
return new Set(props.zaps.filter(a => a.pollOption !== undefined).map(a => unwrap(a.sender))).size;
}
})();
return (
<>
<div className="flex justify-between p">
<small>
<FormattedMessage
defaultMessage="You are voting with {amount} sats"
id="3qnJlS"
values={{
amount: formatShort(prefs.defaultZapAmount),
}}
/>
</small>
<button type="button" onClick={() => setTallyBy(s => (s !== "zaps" ? "zaps" : "pubkeys"))}>
<FormattedMessage
defaultMessage="Votes by {type}"
id="xIcAOU"
values={{
type:
tallyBy === "zaps" ? (
<FormattedMessage defaultMessage="zap" id="5BVs2e" />
) : (
<FormattedMessage defaultMessage="user" id="sUNhQE" />
),
}}
/>
</button>
</div>
<div className="poll-body">
{options.map(a => {
const opt = Number(a[1]);
const desc = a[2];
const zapsOnOption = props.zaps.filter(b => b.pollOption === opt);
const total = (() => {
switch (tallyBy) {
case "zaps":
return zapsOnOption.reduce((acc, v) => (acc += v.amount), 0);
case "pubkeys":
return new Set(zapsOnOption.map(a => unwrap(a.sender))).size;
}
})();
const weight = totalVotes === 0 ? 0 : total / totalVotes;
return (
<div key={a[1]} className="flex" onClick={e => zapVote(e, opt)}>
<div className="grow">{opt === voting ? <Spinner /> : <>{desc}</>}</div>
{showResults && (
<>
<div className="flex">
<FormattedNumber value={weight * 100} maximumFractionDigits={0} />% &nbsp;
<small>({formatShort(total)})</small>
</div>
<div style={{ width: `${weight * 100}%` }} className="progress"></div>
</>
)}
</div>
);
})}
{error && <b className="error">{error}</b>}
</div>
<SendSats show={invoice !== ""} onClose={() => setInvoice("")} invoice={invoice} />
</>
);
}

View File

@ -0,0 +1,117 @@
.reactions-modal .modal-body {
padding: 24px 32px;
background-color: var(--gray-superdark);
border-radius: 16px;
position: relative;
min-height: 33vh;
}
.light .reactions-modal .modal-body {
background-color: var(--gray-superdark);
}
@media (max-width: 720px) {
.reactions-modal .modal-body {
padding: 12px 16px;
max-width: calc(100vw - 32px);
}
}
.reactions-modal .modal-body .close {
position: absolute;
top: 12px;
right: 16px;
color: var(--font-secondary-color);
cursor: pointer;
}
.reactions-modal .modal-body .close:hover {
color: var(--font-tertiary-color);
}
.reactions-modal .modal-body .tabs.p {
padding: 12px 0;
}
.reactions-modal .modal-body .reactions-header {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 12px;
}
.reactions-modal .modal-body .tab {
background: none;
border: none;
}
.reactions-modal .modal-body .tab.active {
background: #fff;
color: #000;
}
.reactions-modal .modal-body .tab:hover {
background: rgba(255, 255, 255, 0.8);
color: #000;
border: none;
}
.reactions-modal .modal-body .reactions-header h2 {
margin: 0;
flex-grow: 1;
font-weight: 600;
font-size: 16px;
line-height: 19px;
}
.reactions-modal .modal-body .reactions-body {
overflow: scroll;
height: 40vh;
-ms-overflow-style: none;
/* for Internet Explorer, Edge */
scrollbar-width: none;
/* Firefox */
margin-top: 12px;
}
.reactions-modal .modal-body .reactions-body::-webkit-scrollbar {
display: none;
}
.reactions-item {
display: grid;
grid-template-columns: 52px auto;
align-items: center;
margin-bottom: 24px;
}
.reactions-item .reaction-icon {
display: flex;
align-items: center;
justify-content: center;
}
.reactions-item .follow-button {
margin-left: auto;
}
.reactions-item .zap-reaction-icon {
width: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.reactions-item .zap-amount {
margin-top: 10px;
font-weight: 500;
font-size: 14px;
line-height: 17px;
}
@media (max-width: 520px) {
.reactions-modal .modal-body .tab.disabled {
display: none;
}
}

View File

@ -0,0 +1,144 @@
import "./Reactions.css";
import { useState, useMemo, useEffect } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { TaggedNostrEvent, ParsedZap } from "@snort/system";
import { formatShort } from "@/Utils/Number";
import Icon from "@/Components/Icons/Icon";
import { Tab } from "@/Components/Tabs/Tabs";
import ProfileImage from "@/Components/User/ProfileImage";
import Tabs from "@/Components/Tabs/Tabs";
import Modal from "@/Components/Modal/Modal";
import messages from "../messages";
import CloseButton from "@/Components/Button/CloseButton";
interface ReactionsProps {
show: boolean;
setShow(b: boolean): void;
positive: TaggedNostrEvent[];
negative: TaggedNostrEvent[];
reposts: TaggedNostrEvent[];
zaps: ParsedZap[];
}
const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: ReactionsProps) => {
const { formatMessage } = useIntl();
const onClose = () => setShow(false);
const likes = useMemo(() => {
const sorted = [...positive];
sorted.sort((a, b) => b.created_at - a.created_at);
return sorted;
}, [positive]);
const dislikes = useMemo(() => {
const sorted = [...negative];
sorted.sort((a, b) => b.created_at - a.created_at);
return sorted;
}, [negative]);
const total = positive.length + negative.length + zaps.length + reposts.length;
const defaultTabs: Tab[] = [
{
text: formatMessage(messages.Likes, { n: likes.length }),
value: 0,
},
{
text: formatMessage(messages.Zaps, { n: zaps.length }),
value: 1,
disabled: zaps.length === 0,
},
{
text: formatMessage(messages.Reposts, { n: reposts.length }),
value: 2,
disabled: reposts.length === 0,
},
];
const tabs = defaultTabs.concat(
dislikes.length !== 0
? [
{
text: formatMessage(messages.Dislikes, { n: dislikes.length }),
value: 3,
},
]
: [],
);
const [tab, setTab] = useState(tabs[0]);
useEffect(() => {
if (!show) {
setTab(tabs[0]);
}
}, [show]);
return show ? (
<Modal id="reactions" className="reactions-modal" onClose={onClose}>
<CloseButton onClick={onClose} className="absolute right-4 top-3" />
<div className="reactions-header">
<h2>
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
</h2>
</div>
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
<div className="reactions-body" key={tab.value}>
{tab.value === 0 &&
likes.map(ev => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">{ev.content === "+" ? <Icon name="heart" /> : ev.content}</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}
{tab.value === 1 &&
zaps.map(z => {
return (
z.sender && (
<div key={z.id} className="reactions-item">
<div className="zap-reaction-icon">
<Icon name="zap" size={20} />
<span className="zap-amount">{formatShort(z.amount)}</span>
</div>
<ProfileImage
showProfileCard={true}
pubkey={z.anonZap ? "" : z.sender}
subHeader={<div title={z.content}>{z.content}</div>}
link={z.anonZap ? "" : undefined}
overrideUsername={
z.anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined
}
/>
</div>
)
);
})}
{tab.value === 2 &&
reposts.map(ev => {
return (
<div key={ev.id} className="reactions-item">
<div className="reaction-icon">
<Icon name="repost" size={16} />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}
{tab.value === 3 &&
dislikes.map(ev => {
return (
<div key={ev.id} className="reactions-item f-ellipsis">
<div className="reaction-icon">
<Icon name="dislike" />
</div>
<ProfileImage pubkey={ev.pubkey} showProfileCard={true} />
</div>
);
})}
</div>
</Modal>
) : null;
};
export default Reactions;

View File

@ -0,0 +1,17 @@
import { WarningNotice } from "@/Components/WarningNotice/WarningNotice";
import { useState } from "react";
interface RevealProps {
message: React.ReactNode;
children: React.ReactNode;
}
export default function Reveal(props: RevealProps) {
const [reveal, setReveal] = useState(false);
if (!reveal) {
return <WarningNotice onClick={() => setReveal(true)}>{props.message}</WarningNotice>;
} else if (props.children) {
return props.children;
}
}

View File

@ -0,0 +1,89 @@
import { FormattedMessage } from "react-intl";
import { FileExtensionRegex } from "@/Utils/Const";
import Reveal from "@/Components/Event/Reveal";
import useLogin from "@/Hooks/useLogin";
import { MediaElement } from "@/Components/Embed/MediaElement";
import { Link } from "react-router-dom";
import { IMeta } from "@snort/system";
interface RevealMediaProps {
creator: string;
link: string;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
meta?: IMeta;
}
export default function RevealMedia(props: RevealMediaProps) {
const { preferences, follows, publicKey } = useLogin(s => ({
preferences: s.appData.item.preferences,
follows: s.follows.item,
publicKey: s.publicKey,
}));
const hideNonFollows = preferences.autoLoadMedia === "follows-only" && !follows.includes(props.creator);
const isMine = props.creator === publicKey;
const hideMedia = preferences.autoLoadMedia === "none" || (!isMine && hideNonFollows);
const hostname = new URL(props.link).hostname;
const url = new URL(props.link);
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
const type = (() => {
switch (extension) {
case "gif":
case "jpg":
case "jpeg":
case "jfif":
case "png":
case "bmp":
case "webp":
return "image";
case "wav":
case "mp3":
case "ogg":
return "audio";
case "mp4":
case "mov":
case "mkv":
case "avi":
case "m4v":
case "webm":
return "video";
default:
return "unknown";
}
})();
if (hideMedia) {
return (
<Reveal
message={
<FormattedMessage
defaultMessage="You don't follow this person, click here to load media from <i>{link}</i>, or update <a><i>your preferences</i></a> to always load media from everybody."
id="HhcAVH"
values={{
i: i => <i>{i}</i>,
a: a => <Link to="/settings/preferences">{a}</Link>,
link: hostname,
}}
/>
}>
<MediaElement
mime={`${type}/${extension}`}
url={url.toString()}
onMediaClick={props.onMediaClick}
meta={props.meta}
/>
</Reveal>
);
} else {
return (
<MediaElement
mime={`${type}/${extension}`}
url={url.toString()}
onMediaClick={props.onMediaClick}
meta={props.meta}
/>
);
}
}

View File

@ -0,0 +1,18 @@
.show-more {
background: none;
border: none;
color: var(--highlight);
font-weight: normal;
}
.show-more:hover {
color: var(--highlight);
background: none;
border: none;
font-weight: normal;
text-decoration: underline;
}
.show-more-container {
min-height: 40px;
}

View File

@ -0,0 +1,39 @@
import "./ShowMore.css";
import { FormattedMessage } from "react-intl";
import { useInView } from "react-intersection-observer";
import { useEffect } from "react";
import classNames from "classnames";
interface ShowMoreProps {
text?: string;
className?: string;
onClick: () => void;
}
const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
return (
<div className="show-more-container">
<button className={classNames("show-more", className)} onClick={onClick}>
{text || <FormattedMessage defaultMessage="Show More" id="O8Z8t9" />}
</button>
</div>
);
};
export default ShowMore;
export function ShowMoreInView({ text, onClick, className }: ShowMoreProps) {
const { ref, inView } = useInView({ rootMargin: "2000px" });
useEffect(() => {
if (inView) {
onClick();
}
}, [inView]);
return (
<div className={classNames("show-more-container", className)} ref={ref}>
{text}
</div>
);
}

View File

@ -0,0 +1,101 @@
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
}
.thread-root.note {
box-shadow: none;
}
.thread-root.note > .body .text {
font-size: 18px;
line-height: 27px;
}
.thread-root.note > .footer {
padding-left: 0;
}
.thread-root.note {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.thread-note.note {
border: 0;
}
.thread-note.note .zaps-summary,
.thread-note.note .footer,
.thread-note.note .body {
margin-left: 61px;
}
.thread-container .hidden-note {
margin: 0;
border-radius: 0;
}
.thread-container .show-more {
background: var(--gray-superdark);
padding-left: 76px;
width: 100%;
text-align: left;
border-radius: 0;
padding-top: 10px;
padding-bottom: 10px;
}
.subthread-container {
position: relative;
}
.subthread-container.subthread-multi .line-container:before {
content: "";
position: absolute;
left: calc(48px / 2 + 16px);
top: 48px;
border-left: 1px solid var(--border-color);
height: 100%;
z-index: 1;
}
.subthread-container.subthread-mid:not(.subthread-last) .line-container:before {
content: "";
position: absolute;
border-left: 1px solid var(--border-color);
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
z-index: 1;
}
.subthread-container.subthread-last .line-container:before {
content: "";
position: absolute;
border-left: 1px solid var(--border-color);
left: calc(48px / 2 + 16px);
top: 0;
height: 48px;
z-index: 1;
}
.divider {
height: 1px;
background: var(--border-color);
}
.divider.divider-small {
margin-left: calc(16px + 61px);
margin-right: 16px;
}
.thread-container .collapsed,
.thread-container .show-more-container {
min-height: 48px;
}
.thread-container .hidden-note {
padding-left: 48px;
}

View File

@ -0,0 +1,354 @@
import "./Thread.css";
import { useMemo, useState, ReactNode, useContext, Fragment } from "react";
import { useIntl } from "react-intl";
import { useNavigate, useParams } from "react-router-dom";
import { TaggedNostrEvent, u256, NostrPrefix, EventExt, parseNostrLink, NostrLink } from "@snort/system";
import classNames from "classnames";
import { getAllLinkReactions, getLinkReactions } from "@/Utils";
import BackButton from "@/Components/Button/BackButton";
import Note from "@/Components/Event/Note";
import NoteGhost from "@/Components/Event/NoteGhost";
import Collapsed from "@/Components/Collapsed";
import { ThreadContext, ThreadContextWrapper, chainKey } from "@/Hooks/useThreadContext";
import messages from "../messages";
interface DividerProps {
variant?: "regular" | "small";
}
const Divider = ({ variant = "regular" }: DividerProps) => {
const className = variant === "small" ? "divider divider-small" : "divider";
return (
<div className="divider-container">
<div className={className}></div>
</div>
);
};
interface SubthreadProps {
isLastSubthread?: boolean;
active: u256;
notes: readonly TaggedNostrEvent[];
related: readonly TaggedNostrEvent[];
chains: Map<u256, Array<TaggedNostrEvent>>;
onNavigate: (e: TaggedNostrEvent) => void;
}
const Subthread = ({ active, notes, related, chains, onNavigate }: SubthreadProps) => {
const renderSubthread = (a: TaggedNostrEvent, idx: number) => {
const isLastSubthread = idx === notes.length - 1;
const replies = getReplies(a.id, chains);
return (
<Fragment key={a.id}>
<div className={`subthread-container ${replies.length > 0 ? "subthread-multi" : ""}`}>
<Divider />
<Note
highlight={active === a.id}
className={`thread-note ${isLastSubthread && replies.length === 0 ? "is-last-note" : ""}`}
data={a}
key={a.id}
related={related}
onClick={onNavigate}
threadChains={chains}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierTwo
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
</Fragment>
);
};
return <div className="subthread">{notes.map(renderSubthread)}</div>;
};
interface ThreadNoteProps extends Omit<SubthreadProps, "notes"> {
note: TaggedNostrEvent;
isLast: boolean;
}
const ThreadNote = ({ active, note, isLast, isLastSubthread, related, chains, onNavigate }: ThreadNoteProps) => {
const { formatMessage } = useIntl();
const replies = getReplies(note.id, chains);
const activeInReplies = replies.map(r => r.id).includes(active);
const [collapsed, setCollapsed] = useState(!activeInReplies);
const hasMultipleNotes = replies.length > 1;
const isLastVisibleNote = isLastSubthread && isLast && !hasMultipleNotes;
const className = classNames(
"subthread-container",
isLast && collapsed ? "subthread-last" : "subthread-multi subthread-mid",
);
return (
<>
<div className={className}>
<Divider variant="small" />
<Note
highlight={active === note.id}
className={classNames("thread-note", { "is-last-note": isLastVisibleNote })}
data={note}
key={note.id}
related={related}
onClick={onNavigate}
threadChains={chains}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<Collapsed text={formatMessage(messages.ShowReplies)} collapsed={collapsed} setCollapsed={setCollapsed}>
<TierThree
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
</Collapsed>
)}
</>
);
};
const TierTwo = ({ active, isLastSubthread, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
return (
<>
<ThreadNote
active={active}
onNavigate={onNavigate}
note={first}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={rest.length === 0}
/>
{rest.map((r: TaggedNostrEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
return (
<ThreadNote
key={r.id}
active={active}
onNavigate={onNavigate}
note={r}
chains={chains}
related={related}
isLastSubthread={isLastSubthread}
isLast={lastReply}
/>
);
})}
</>
);
};
const TierThree = ({ active, isLastSubthread, notes, related, chains, onNavigate }: SubthreadProps) => {
const [first, ...rest] = notes;
const replies = getReplies(first.id, chains);
const hasMultipleNotes = rest.length > 0 || replies.length > 0;
const isLast = replies.length === 0 && rest.length === 0;
return (
<>
<div
className={classNames("subthread-container", {
"subthread-multi": hasMultipleNotes,
"subthread-last": isLast,
"subthread-mid": !isLast,
})}>
<Divider variant="small" />
<Note
highlight={active === first.id}
className={classNames("thread-note", { "is-last-note": isLastSubthread && isLast })}
data={first}
key={first.id}
related={related}
threadChains={chains}
/>
<div className="line-container"></div>
</div>
{replies.length > 0 && (
<TierThree
active={active}
isLastSubthread={isLastSubthread}
notes={replies}
related={related}
chains={chains}
onNavigate={onNavigate}
/>
)}
{rest.map((r: TaggedNostrEvent, idx: number) => {
const lastReply = idx === rest.length - 1;
const lastNote = isLastSubthread && lastReply;
return (
<div
key={r.id}
className={classNames("subthread-container", {
"subthread-multi": !lastReply,
"subthread-last": !lastReply,
"subthread-mid": lastReply,
})}>
<Divider variant="small" />
<Note
className={classNames("thread-note", { "is-last-note": lastNote })}
highlight={active === r.id}
data={r}
key={r.id}
related={related}
onClick={onNavigate}
threadChains={chains}
/>
<div className="line-container"></div>
</div>
);
})}
</>
);
};
export function ThreadRoute({ id }: { id?: string }) {
const params = useParams();
const resolvedId = id ?? params.id;
const link = parseNostrLink(resolvedId ?? "", NostrPrefix.Note);
return (
<ThreadContextWrapper link={link}>
<Thread />
</ThreadContextWrapper>
);
}
export function Thread(props: { onBack?: () => void; disableSpotlight?: boolean }) {
const thread = useContext(ThreadContext);
const navigate = useNavigate();
const isSingleNote = thread.chains?.size === 1 && [thread.chains.values].every(v => v.length === 0);
const { formatMessage } = useIntl();
function navigateThread(e: TaggedNostrEvent) {
thread.setCurrent(e.id);
//router.navigate(`/${NostrLink.fromEvent(e).encode()}`, { replace: true })
}
const parent = useMemo(() => {
if (thread.root) {
const currentThread = EventExt.extractThread(thread.root);
return (
currentThread?.replyTo?.value ??
currentThread?.root?.value ??
(currentThread?.root?.key === "a" && currentThread.root?.value)
);
}
}, [thread.root]);
function renderRoot(note: TaggedNostrEvent) {
const className = `thread-root${isSingleNote ? " thread-root-single" : ""}`;
if (note) {
return (
<Note
className={className}
key={note.id}
data={note}
related={getLinkReactions(thread.reactions, NostrLink.fromEvent(note))}
options={{ showReactionsLink: true, showMediaSpotlight: !props.disableSpotlight, isRoot: true }}
onClick={navigateThread}
threadChains={thread.chains}
/>
);
} else {
return <NoteGhost className={className}>Loading thread root.. ({thread.data?.length} notes loaded)</NoteGhost>;
}
}
function renderChain(from: u256): ReactNode {
if (!from || thread.chains.size === 0) {
return;
}
const replies = thread.chains.get(from);
if (replies && thread.current) {
return (
<Subthread
active={thread.current}
notes={replies}
related={getAllLinkReactions(
thread.reactions,
replies.map(a => NostrLink.fromEvent(a)),
)}
chains={thread.chains}
onNavigate={navigateThread}
/>
);
}
}
function goBack() {
if (parent) {
thread.setCurrent(parent);
} else if (props.onBack) {
props.onBack();
} else {
navigate(-1);
}
}
const parentText = formatMessage({
defaultMessage: "Parent",
id: "ADmfQT",
description: "Link to parent note in thread",
});
const debug = window.location.search.includes("debug=true");
return (
<>
{debug && (
<div className="main-content p xs">
<h1>Chains</h1>
<pre>
{JSON.stringify(
Object.fromEntries([...thread.chains.entries()].map(([k, v]) => [k, v.map(c => c.id)])),
undefined,
" ",
)}
</pre>
<h1>Current</h1>
<pre>{JSON.stringify(thread.current)}</pre>
<h1>Root</h1>
<pre>{JSON.stringify(thread.root, undefined, " ")}</pre>
<h1>Data</h1>
<pre>{JSON.stringify(thread.data, undefined, " ")}</pre>
<h1>Reactions</h1>
<pre>{JSON.stringify(thread.reactions, undefined, " ")}</pre>
</div>
)}
{parent && (
<div className="main-content p">
<BackButton onClick={goBack} text={parentText} />
</div>
)}
<div className="main-content">
{thread.root && renderRoot(thread.root)}
{thread.root && renderChain(chainKey(thread.root))}
</div>
</>
);
}
function getReplies(from: u256, chains?: Map<u256, Array<TaggedNostrEvent>>): Array<TaggedNostrEvent> {
if (!from || !chains) {
return [];
}
const replies = chains.get(from);
return replies ? replies : [];
}

View File

@ -0,0 +1,93 @@
.zap {
min-height: unset;
}
.zap .header {
align-items: center;
flex-direction: row;
}
.zap .header .amount {
font-size: 24px;
}
@media (max-width: 520px) {
.zap .header .amount {
font-size: 16px;
}
}
.zap .header .pfp {
max-width: 72%;
}
@media (max-width: 520px) {
.zap .header .pfp {
padding: 4px;
}
}
.zap .summary {
display: flex;
flex-direction: row;
}
.zap .amount {
font-size: 18px;
}
.top-zap .amount:before {
content: "";
}
.top-zap .summary {
color: var(--font-secondary-color);
}
.zaps-summary {
display: flex;
flex-direction: row;
}
.note.thread-root .zaps-summary {
margin-left: 14px;
}
.top-zap {
font-size: 14px;
border: none;
margin: 0;
}
.top-zap .pfp {
margin-right: 0.3em;
}
.top-zap .summary .pfp .avatar-wrapper .avatar {
width: 18px;
height: 18px;
}
.top-zap .nip05 {
display: none;
}
.top-zap .summary {
display: flex;
flex-direction: row;
align-items: center;
}
.amount-number {
font-weight: 500;
}
.zap.note .body {
margin-bottom: 0;
}
@media (max-width: 420px) {
.zap .nip05 .badge {
margin: 0 0 0 0.3em;
}
}

View File

@ -0,0 +1,79 @@
import "./Zap.css";
import { useMemo } from "react";
import { ParsedZap } from "@snort/system";
import { FormattedMessage, useIntl } from "react-intl";
import { unwrap } from "@/Utils";
import { formatShort } from "@/Utils/Number";
import Text from "@/Components/Text/Text";
import ProfileImage from "@/Components/User/ProfileImage";
import useLogin from "@/Hooks/useLogin";
import messages from "../messages";
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap; showZapped?: boolean }) => {
const { amount, content, sender, valid, receiver } = zap;
const pubKey = useLogin().publicKey;
return valid && sender ? (
<div className="card">
<div className="flex justify-between">
<ProfileImage pubkey={sender} showProfileCard={true} />
{receiver !== pubKey && showZapped && <ProfileImage pubkey={unwrap(receiver)} />}
<h3>
<FormattedMessage {...messages.Sats} values={{ n: formatShort(amount ?? 0) }} />
</h3>
</div>
{(content?.length ?? 0) > 0 && sender && (
<Text id={zap.id} creator={sender} content={unwrap(content)} tags={[]} />
)}
</div>
) : null;
};
interface ZapsSummaryProps {
zaps: ParsedZap[];
}
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
const { formatMessage } = useIntl();
const sortedZaps = useMemo(() => {
const pub = [...zaps.filter(z => z.sender && z.valid)];
const priv = [...zaps.filter(z => !z.sender && z.valid)];
pub.sort((a, b) => b.amount - a.amount);
return pub.concat(priv);
}, [zaps]);
if (zaps.length === 0) {
return null;
}
const [topZap, ...restZaps] = sortedZaps;
const { sender, amount, anonZap } = topZap;
return (
<div className="zaps-summary">
{amount && (
<div className={`top-zap`}>
<div className="summary">
{sender && (
<ProfileImage
pubkey={anonZap ? "" : sender}
showFollowDistance={false}
overrideUsername={anonZap ? formatMessage({ defaultMessage: "Anonymous", id: "LXxsbk" }) : undefined}
/>
)}
{restZaps.length > 0 ? (
<FormattedMessage {...messages.Others} values={{ n: restZaps.length }} />
) : (
<FormattedMessage {...messages.Zapped} />
)}{" "}
<FormattedMessage {...messages.OthersZapped} values={{ n: restZaps.length }} />
</div>
</div>
)}
</div>
);
};
export default Zap;

View File

@ -0,0 +1,7 @@
.zap-button {
color: var(--bg-color);
background-color: var(--highlight);
padding: 4px 8px;
border-radius: 16px;
cursor: pointer;
}

View File

@ -0,0 +1,50 @@
import "./ZapButton.css";
import { useState } from "react";
import { HexKey } from "@snort/system";
import { useUserProfile } from "@snort/system-react";
import SendSats from "@/Components/SendSats/SendSats";
import Icon from "@/Components/Icons/Icon";
import { ZapTarget } from "@/Utils/Zapper";
const ZapButton = ({
pubkey,
lnurl,
children,
event,
}: {
pubkey: HexKey;
lnurl?: string;
children?: React.ReactNode;
event?: string;
}) => {
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
const service = lnurl ?? (profile?.lud16 || profile?.lud06);
if (!service) return null;
return (
<>
<button type="button" className="flex g8" onClick={() => setZap(true)}>
<Icon name="zap-solid" />
{children}
</button>
<SendSats
targets={[
{
type: "lnurl",
value: service,
weight: 1,
name: profile?.display_name || profile?.name,
zap: { pubkey: pubkey },
} as ZapTarget,
]}
show={zap}
onClose={() => setZap(false)}
note={event}
/>
</>
);
};
export default ZapButton;

View File

@ -0,0 +1,3 @@
.zap-goal h1 {
line-height: 1em;
}

View File

@ -0,0 +1,41 @@
import "./ZapGoal.css";
import { useState } from "react";
import { NostrEvent, NostrLink } from "@snort/system";
import useZapsFeed from "@/Feed/ZapsFeed";
import { formatShort } from "@/Utils/Number";
import { findTag } from "@/Utils";
import Icon from "@/Components/Icons/Icon";
import SendSats from "../SendSats/SendSats";
import { Zapper } from "@/Utils/Zapper";
import Progress from "@/Components/Progress/Progress";
import { FormattedNumber } from "react-intl";
export function ZapGoal({ ev }: { ev: NostrEvent }) {
const [zap, setZap] = useState(false);
const zaps = useZapsFeed(NostrLink.fromEvent(ev));
const target = Number(findTag(ev, "amount"));
const amount = zaps.reduce((acc, v) => (acc += v.amount * 1000), 0);
const progress = amount / target;
return (
<div className="zap-goal card">
<div className="flex items-center justify-between">
<h2>{ev.content}</h2>
<div className="zap-button flex" onClick={() => setZap(true)}>
<Icon name="zap" size={15} />
</div>
<SendSats targets={Zapper.fromEvent(ev)} show={zap} onClose={() => setZap(false)} />
</div>
<div className="flex justify-between">
<div>
<FormattedNumber value={progress} style="percent" />
</div>
<div>
{formatShort(amount / 1000)}/{formatShort(target / 1000)}
</div>
</div>
<Progress value={progress} />
</div>
);
}

View File

@ -0,0 +1,9 @@
import { transformTextCached } from "@/Hooks/useTextTransformCache";
import { TaggedNostrEvent } from "@snort/system";
export default function getEventMedia(event: TaggedNostrEvent) {
const parsed = transformTextCached(event.id, event.content, event.tags);
return parsed.filter(
a => a.type === "media" && (a.mimeType?.startsWith("image/") || a.mimeType?.startsWith("video/")),
);
}