reorganize code into smaller files & dirs
This commit is contained in:
122
packages/app/src/Components/Event/Create/NoteCreator.css
Normal file
122
packages/app/src/Components/Event/Create/NoteCreator.css
Normal 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);
|
||||
}
|
691
packages/app/src/Components/Event/Create/NoteCreator.tsx
Normal file
691
packages/app/src/Components/Event/Create/NoteCreator.tsx
Normal 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> </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>
|
||||
);
|
||||
}
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
64
packages/app/src/Components/Event/Create/OkResponseRow.tsx
Normal file
64
packages/app/src/Components/Event/Create/OkResponseRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
28
packages/app/src/Components/Event/Create/util.ts
Normal file
28
packages/app/src/Components/Event/Create/util.ts
Normal 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;
|
||||
}
|
||||
}
|
15
packages/app/src/Components/Event/FileUpload.tsx
Normal file
15
packages/app/src/Components/Event/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
23
packages/app/src/Components/Event/HiddenNote.tsx
Normal file
23
packages/app/src/Components/Event/HiddenNote.tsx
Normal 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;
|
67
packages/app/src/Components/Event/LongFormText.css
Normal file
67
packages/app/src/Components/Event/LongFormText.css
Normal 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;
|
||||
}
|
||||
}
|
168
packages/app/src/Components/Event/LongFormText.tsx
Normal file
168
packages/app/src/Components/Event/LongFormText.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
packages/app/src/Components/Event/Markdown.css
Normal file
44
packages/app/src/Components/Event/Markdown.css
Normal 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;
|
||||
}
|
136
packages/app/src/Components/Event/Markdown.tsx
Normal file
136
packages/app/src/Components/Event/Markdown.tsx
Normal 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 };
|
51
packages/app/src/Components/Event/NostrFileHeader.tsx
Normal file
51
packages/app/src/Components/Event/NostrFileHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
176
packages/app/src/Components/Event/Note.css
Normal file
176
packages/app/src/Components/Event/Note.css
Normal 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);
|
||||
}
|
90
packages/app/src/Components/Event/Note.tsx
Normal file
90
packages/app/src/Components/Event/Note.tsx
Normal 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>;
|
||||
}
|
213
packages/app/src/Components/Event/NoteContextMenu.tsx
Normal file
213
packages/app/src/Components/Event/NoteContextMenu.tsx
Normal 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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
329
packages/app/src/Components/Event/NoteFooter.tsx
Normal file
329
packages/app/src/Components/Event/NoteFooter.tsx
Normal 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";
|
20
packages/app/src/Components/Event/NoteGhost.tsx
Normal file
20
packages/app/src/Components/Event/NoteGhost.tsx
Normal 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>
|
||||
);
|
||||
}
|
415
packages/app/src/Components/Event/NoteInner.tsx
Normal file
415
packages/app/src/Components/Event/NoteInner.tsx
Normal 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] && (
|
||||
<>
|
||||
|
||||
<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:
|
||||
{(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;
|
||||
}
|
27
packages/app/src/Components/Event/NoteQuote.tsx
Normal file
27
packages/app/src/Components/Event/NoteQuote.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
14
packages/app/src/Components/Event/NoteReaction.css
Normal file
14
packages/app/src/Components/Event/NoteReaction.css
Normal 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;
|
||||
}
|
99
packages/app/src/Components/Event/NoteReaction.tsx
Normal file
99
packages/app/src/Components/Event/NoteReaction.tsx
Normal 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>
|
||||
);
|
||||
}
|
74
packages/app/src/Components/Event/NoteTime.tsx
Normal file
74
packages/app/src/Components/Event/NoteTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
178
packages/app/src/Components/Event/Poll.tsx
Normal file
178
packages/app/src/Components/Event/Poll.tsx
Normal 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} />%
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
}
|
117
packages/app/src/Components/Event/Reactions.css
Normal file
117
packages/app/src/Components/Event/Reactions.css
Normal 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;
|
||||
}
|
||||
}
|
144
packages/app/src/Components/Event/Reactions.tsx
Normal file
144
packages/app/src/Components/Event/Reactions.tsx
Normal 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;
|
17
packages/app/src/Components/Event/Reveal.tsx
Normal file
17
packages/app/src/Components/Event/Reveal.tsx
Normal 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;
|
||||
}
|
||||
}
|
89
packages/app/src/Components/Event/RevealMedia.tsx
Normal file
89
packages/app/src/Components/Event/RevealMedia.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
18
packages/app/src/Components/Event/ShowMore.css
Normal file
18
packages/app/src/Components/Event/ShowMore.css
Normal 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;
|
||||
}
|
39
packages/app/src/Components/Event/ShowMore.tsx
Normal file
39
packages/app/src/Components/Event/ShowMore.tsx
Normal 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>
|
||||
);
|
||||
}
|
101
packages/app/src/Components/Event/Thread.css
Normal file
101
packages/app/src/Components/Event/Thread.css
Normal 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;
|
||||
}
|
354
packages/app/src/Components/Event/Thread.tsx
Normal file
354
packages/app/src/Components/Event/Thread.tsx
Normal 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 : [];
|
||||
}
|
93
packages/app/src/Components/Event/Zap.css
Normal file
93
packages/app/src/Components/Event/Zap.css
Normal 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;
|
||||
}
|
||||
}
|
79
packages/app/src/Components/Event/Zap.tsx
Normal file
79
packages/app/src/Components/Event/Zap.tsx
Normal 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;
|
7
packages/app/src/Components/Event/ZapButton.css
Normal file
7
packages/app/src/Components/Event/ZapButton.css
Normal file
@ -0,0 +1,7 @@
|
||||
.zap-button {
|
||||
color: var(--bg-color);
|
||||
background-color: var(--highlight);
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
}
|
50
packages/app/src/Components/Event/ZapButton.tsx
Normal file
50
packages/app/src/Components/Event/ZapButton.tsx
Normal 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;
|
3
packages/app/src/Components/Event/ZapGoal.css
Normal file
3
packages/app/src/Components/Event/ZapGoal.css
Normal file
@ -0,0 +1,3 @@
|
||||
.zap-goal h1 {
|
||||
line-height: 1em;
|
||||
}
|
41
packages/app/src/Components/Event/ZapGoal.tsx
Normal file
41
packages/app/src/Components/Event/ZapGoal.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
packages/app/src/Components/Event/getEventMedia.ts
Normal file
9
packages/app/src/Components/Event/getEventMedia.ts
Normal 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/")),
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user